Skip to content

Cyclic TaskGroup dependencies break Grid/Graph since 3.3.1; proposed parse-time rejection is a breaking change #73678

Description

@dheerajturaga

Starting with Airflow 3.3.1, the Grid and Graph views return HTTP 500 for Dags whose TaskGroups have dependencies that form a cycle when each group is treated as a single unit, even though the task-level graph is acyclic. These Dags parse, schedule and run normally, and they rendered in the UI up to and including 3.3.0.

One proposed change (#73087) would reject these Dags at parse time. That is also a breaking change for existing Dags, this time with an import error. Before choosing a direction, I'd like community input on:

  • how to handle the regression,
  • whether these Dags should be allowed at all,
  • how any change should be rolled out and communicated.

I'm affected by this on my own Dags as well.

How we got here

Affected Dag shapes

A task with no upstream task inside its own group counts as a root of that group, even when a task outside the group provides its only upstream. Group-level edges are built from those roots.

1. Sibling groups with dependencies in both directions:

from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk import DAG, TaskGroup

with DAG("tg_cycle", schedule=None):
    with TaskGroup("group1"):
        a1 = EmptyOperator(task_id="a1")
        a2 = EmptyOperator(task_id="a2")
    with TaskGroup("group2"):
        b1 = EmptyOperator(task_id="b1")
        b2 = EmptyOperator(task_id="b2")

    a1 >> b1  # group1 -> group2
    b2 >> a2  # group2 -> group1

2. Two tasks in one group bridged by a task outside it:

with DAG("tg_bridged", schedule=None):
    with TaskGroup("g"):
        a = EmptyOperator(task_id="a")
        b = EmptyOperator(task_id="b")
    ext = EmptyOperator(task_id="ext")

    a >> ext >> b  # g -> ext -> g

Both are acyclic at the task level. This pattern is common when TaskGroups are used for logical or visual grouping (for example one group per database schema) rather than as execution units. Users report hitting it when upgrading from 2.x to 3.3.1.

Impact by version

Version Parsing / scheduling Grid / Graph view
≤ 3.3.0 Works Renders, but group order can be wrong
3.3.1, 3.3.2 Works HTTP 500
With #73087 Import error, Dag not loaded N/A

Open questions for the community

  1. Should these Dags be allowed at all?
  2. How should the change be rolled out, given the deprecation policy? Options include:
  3. What should 3.3.x users do in the meantime? Currently the only workaround is to restructure the Dag: remove the cycle or flatten the affected TaskGroups. Tasks keep running and stay reachable through the REST API.
  4. Communication: a significant newsfragment exists in Reject cyclic TaskGroup dependencies during Dag parsing #73087. Should there also be a known-issue note in the 3.3.1/3.3.2 release notes and/or an announcement on the dev/users mailing list?

Related

Are you willing to submit PR?

  • Yes. I'm willing to submit PRs for whichever direction the community agrees on.

Drafted-by: Claude Code (Opus 5.5); reviewed by @dheerajturaga before posting

Activity

  1. dheerajturaga commented on Sep 24, 2026

    @dheerajturaga
    MemberAuthor

    Another case, found by @shubhamraj-git while reviewing #73087 (#73087 (comment)): a Dag whose group-level dependencies have no cycle can still get a 500 from Grid/Graph when the view is filtered.

    with DAG("filter_cycle", schedule=None):
        with TaskGroup("g"):
            first = EmptyOperator(task_id="first")
            second = EmptyOperator(task_id="second")
            guard = EmptyOperator(task_id="guard")
            guard >> second
        bridge = EmptyOperator(task_id="bridge")
        first >> bridge >> second

    Unfiltered Grid/Graph return 200. With root=g.first&include_downstream=true, partial_subset() drops g.guard, so g.second becomes a root of g. The group-level edges then become g ↔ bridge, and both endpoints return 500. The same failure happens without #73087.

    So filtering can create group-level cycles even for Dags that pass any parse-time check. Whatever we decide on rejecting these Dags, the Grid/Graph ordering needs to tolerate group-level cycles rather than error out. I'll handle that in a separate PR.


    Drafted-by: Claude Code (Opus 5.5); reviewed by @dheerajturaga before posting

  2. rloredo commented on Oct 1, 2026

    @rloredo

    We use task groups as a visual aid for grouping tasks not to create dependencies.
    Disallowing these type of dags needs to come with an alternative to organize tasks in "folders".

  3. dheerajturaga commented on Oct 7, 2026

    @dheerajturaga
    MemberAuthor

    Thanks @rloredo. Many people use TaskGroups as folders, and you're not the only one affected. The same came up on #73087 (one group per database schema) and #73724 (dbt models in a group with a Spark task between two of them).

    Here's what the dev list agreed (discussion, result):

    Until 3.4.0, only Grid and Graph are affected. Tasks keep running and the REST API still works.

    The reason for the change is that planned TaskGroup features, such as loops, dynamic TaskGroups, retrying a group and waiting for a group to finish, need each group to come either before or after its neighbours. A cycle makes that impossible.

    The rule: a dependency into or out of any task in a group counts as a dependency of the whole group. So a path that leaves a group and comes back into it (g.a >> x >> g.b) is a cycle.

    How to restructure. One of these usually works without changing any task dependencies:

    1. Move the task between the two group tasks into the group, or move the downstream task out of it.
    2. Split the group along the direction of the data flow. If you use one group per schema and have dependencies across schemas, group by stage first and by schema second:
    with TaskGroup("load"):
        with TaskGroup("sales"):
            load_sales = EmptyOperator(task_id="load")
        with TaskGroup("customers"):
            load_customers = EmptyOperator(task_id="load")
    
    with TaskGroup("report"):
        with TaskGroup("sales"):
            report_sales = EmptyOperator(task_id="report")
        with TaskGroup("customers"):
            report_customers = EmptyOperator(task_id="report")
    
    [load_sales, load_customers] >> report_sales
    [load_sales, load_customers] >> report_customers

    This changes task IDs (for example, sales.load becomes load.sales.load). Check anything that refers to tasks by ID, such as sensors or xcom_pull calls.

    Once 3.4.0 is out, you can find affected Dags in CI by running your Dag tests with -W error::airflow.sdk.exceptions.TaskGroupCycleDeprecationWarning.

    A folder-only alternative. That's a fair ask, and Airflow doesn't have one today. Rejection in 3.5 is the plan. The dev list discussion also said we should check how common this pattern is and reconsider if it would break many Dags. Options include letting a Dag opt out of group-level features, or a grouping that's only for display. The 3.4 deprecation period is the time to collect that information. If you're affected, please reply here with:

    • an example of your Dag's shape, with any sensitive names removed (or which of the two shapes in the issue description it matches),
    • roughly how many of your Dags are affected,
    • whether the restructuring above works for you, and if it doesn't, why not.

    If enough users can't restructure, I'll raise it on the dev list again before 3.5.


    Drafted-by: Claude Code (Opus 5.5); reviewed by @dheerajturaga before posting

  4. dheerajturaga commented on Oct 7, 2026

    @dheerajturaga
    MemberAuthor

    Scope note from #73746: the deprecation warning, and the parse-time rejection as currently planned, live in the Python SDK's DAG.check_cycle(). Go, Java and TypeScript SDK Dags reach the Dag processor already serialized, and lang_sdk_processor.py only validates them, so they get neither the warning nor the rejection. Before the rejection lands in 3.5, the check should run on SerializedTaskGroup in core (for example from lang_sdk_processor), so it covers every SDK. If that doesn't make 3.5, the rejection should be documented as Python-only.


    Drafted-by: Claude Code (Opus 5.5); reviewed by @dheerajturaga before posting

  5. michiel-de-muynck commented on Oct 9, 2026

    @michiel-de-muynck

    The main unresolved issue I see here still is the conflict between:

    • The Airflow deprecation policy promising not to make breaking changes in minor versions
    • The current plan of doing exactly that, for a change that actually breaks real-world code (examples: 1, 2, 3)

    There are multiple possible solutions to this:

    • Update the deprecation policy to reflect reality, that semantic versioning is no longer used
    • Rename the planned version 3.5 to 4.0
    • Make the DAG only invalid if the new features for which these DAG structures are problematic are actually used

    Just ignoring the discrepancy seems like the worst choice.

  6. dheerajturaga commented on Oct 9, 2026

    @dheerajturaga
    MemberAuthor

    @michiel-de-muynck The dev list treated this as a bug fix, not as removing a feature. The deprecation policy covers supported behavior. Airflow never defined how TaskGroups that depend on each other in a cycle should behave: before 3.3.1, Grid and Graph ignored the edges between groups and showed them in an arbitrary order (#65291), and the planned features that act on a TaskGroup as a whole (loops, dynamic TaskGroups, retrying a group, waiting for a group to finish) have no defined behavior for them. The release process docs also note that SemVer isn't a promise of 100% compatibility, because a bug to one person can be a feature another person depends on.

    We still didn't want these Dags to start failing without notice. So 3.4 first warns at parse time and in the UI, with guidance on how to restructure, and the rejection comes in 3.5. The lazy consensus agreed on this as an explicit exception, so I don't plan to propose a change to the deprecation policy for it.

    If you think the decision is wrong, the dev list is the place to raise it. As mentioned above, if the 3.4 period shows that many users can't restructure their Dags, I'll take it back to the list before 3.5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions