DEV Community

Cover image for How we cut our Dagster Cloud bill by 60% by collapsing our dbt assets
Oskar
Oskar

Posted on

How we cut our Dagster Cloud bill by 60% by collapsing our dbt assets

If your team is using Dagster Cloud's Solo or Starter tiers, the May 2026 pricing update probably gave you a bit of a shock. By removing the monthly credit allowance and charging for each step-executed asset materialization, workspaces that used to cost $10–$100 a month suddenly spiked into the hundreds or even over a thousand dollars.

At Narev, we love the developer experience of Dagster, but our 24/7 schedule was driving up our bill to the point where self-hosting Airflow was starting to look attractive.

Before jumping ship, we audited our setup and found a massive quick win: we were paying Dagster to coordinate a graph that dbt already understood. Here is how we fixed it and cut our orchestration bill by 60%.

The culprit: @dbt_assets

The standard dagster-dbt component is beautiful. You use @dbt_assets, and Dagster maps every single dbt model and seed into an individual node in your Dagster UI.

But under the new pricing model, every asset materialization has a cost (around $0.035 to $0.040 per credit). If you have a couple hundred dbt models running on an hourly or daily partition, you are burning credits just to have Dagster say, "Yep, dbt built this view."

The fix: One asset, One command

We decided to collapse our entire dbt project into a single plain @asset that just shells out to dbt. Same dbt logic, same data, but far fewer orchestration steps.
Here is what our refactored asset looks like:

import json
from dagster import AssetExecutionContext, asset
from dagster_dbt import DbtCliResource
from etl.defs.partitions import daily_partition
from etl.defs.utils import DeploymentType, get_deployment_type

@asset(
    partitions_def=daily_partition,
    group_name="transformers",
    deps=["dw_load"],
)
def dw_transform(context: AssetExecutionContext, dbt_resource: DbtCliResource):
    time_window = context.partition_time_window
    deployment = get_deployment_type()
    dbt_target = "prod" if deployment == DeploymentType.PROD else "dev"

    dbt_vars = {
        "start_date": time_window.start.strftime("%Y-%m-%d"),
        "end_date": time_window.end.strftime("%Y-%m-%d"),
    }

    args = ["build", "--target", dbt_target, "--vars", json.dumps(dbt_vars)]

    # The crucial part:
    result = dbt_resource.cli(args).wait()

    if not result.is_successful():
        raise Exception("dbt build failed")

    return "All models updated"
Enter fullscreen mode Exit fullscreen mode

The gotcha: Drop the context

If you try this, there is one massive trap you need to avoid. Do not pass context=context to dbt_resource.cli().

If you do result = dbt_resource.cli(args, context=context).wait(), the dagster-dbt package will still emit individual materialization events behind the scenes for every single model. You will end up paying for per-model orchestration without getting the per-model UI benefits.

Using .wait() instead of .stream() and dropping the context ensures you only pay for one materialization per partition per run.

The trade-offs

This optimization isn't entirely free. By hiding dbt's complexity from Dagster, you are giving up a few things:

  • No per-model UI lineage: You won't see individual staging models in the Dagster graph.
  • Coarser alerting: If a single model fails, the entire dw_transform asset goes red. You'll have to check the dbt logs to see exactly which model broke.

For us, this was a no-brainer. We rely on dbt docs and our warehouse for deep lineage anyway, and we only need Dagster to know: "Extract done → Load done → Transform done."

If you want to see exactly how this changes downstream asset dependencies and how to wire up your jobs with this new pattern, I wrote a full breakdown on our blog (along with a drop-in Cursor prompt to automate the refactor for your workspace).

Read the full guide on the Narev blog

Top comments (0)