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)