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"
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)