Orchestrate Excel Reports with Dagster
A monthly Excel report usually starts as one script: read the export, clean it, write the workbook, email it. When it grows to several reports sharing the same cleaned data, needs re-running for a past month, and should stop before sending if the numbers look wrong, cron and a script stop being enough. Dagster approaches the problem through the data each step produces rather than the tasks. You declare assets — the source table, the cleaned table, the report workbook — and the functions that compute them, and Dagster works out the order, records every run, re-runs any month on demand, and shows the lineage in its web UI. This page models an Excel report as Dagster assets, adds monthly partitions, validation checks and a schedule, and tests the whole pipeline in-process. It belongs to Scheduling Python Excel Scripts with Cron.
Prerequisites
pip install dagster dagster-webserver pandas openpyxl
The source system drops one export per month into inbox/ — sales_2026-08.xlsx and sales_2026-09.xlsx, with the usual problems: a region typed as "north ", and a missing revenue in August. The examples were run with Dagster 1.13; dagster-webserver provides the UI and is only needed for Step 6.
Step 1 — Describe the report as assets
Each asset is a function decorated with @asset. Its parameters name the assets it depends on, so the dependency graph comes from the function signatures:
import dagster as dg
import pandas as pd
monthly = dg.MonthlyPartitionsDefinition(start_date="2026-08-01")
class ReportPaths(dg.ConfigurableResource):
inbox: str = "inbox"
outbox: str = "outbox"
@dg.asset(partitions_def=monthly, group_name="sales_report")
def raw_sales(context: dg.AssetExecutionContext, paths: ReportPaths) -> pd.DataFrame:
"""The monthly export as delivered."""
month = context.partition_key[:7]
df = pd.read_excel(f"{paths.inbox}/sales_{month}.xlsx")
context.add_output_metadata({"rows": len(df), "source": f"sales_{month}.xlsx"})
return df
@dg.asset(partitions_def=monthly, group_name="sales_report")
def clean_sales(raw_sales: pd.DataFrame) -> pd.DataFrame:
"""Trimmed, title-cased regions; missing revenue treated as zero."""
return raw_sales.assign(Region=raw_sales["Region"].str.strip().str.title(),
Revenue=raw_sales["Revenue"].fillna(0.0))
@dg.asset(partitions_def=monthly, group_name="sales_report")
def sales_report_xlsx(context: dg.AssetExecutionContext, clean_sales: pd.DataFrame, paths: ReportPaths) -> str:
"""The workbook sent to finance."""
from pathlib import Path
month = context.partition_key[:7]
Path(paths.outbox).mkdir(exist_ok=True)
out = f"{paths.outbox}/sales-report_{month}.xlsx"
summary = clean_sales.groupby("Region", as_index=False)["Revenue"].sum()
with pd.ExcelWriter(out) as xw:
summary.to_excel(xw, sheet_name="Summary", index=False)
clean_sales.to_excel(xw, sheet_name="Detail", index=False)
context.add_output_metadata({"report_file": out, "total": float(summary["Revenue"].sum())})
return out
print([a.key.to_user_string() for a in (raw_sales, clean_sales, sales_report_xlsx)])
clean_sales(raw_sales) depends on raw_sales simply by naming it as a parameter, and sales_report_xlsx depends on clean_sales. Each asset's return value is stored between steps by Dagster's I/O manager (pickled to local disk by default), so a failed report step can be retried without re-reading the source. add_output_metadata attaches facts — row counts, the output path, the total — that appear in the UI next to every materialisation, which is often the quickest way to answer "what did last month's run produce?". The paths parameter is a resource: configuration injected at run time, covered in Step 3.
Step 2 — Validate before anyone sees the report
An asset check runs after an asset is materialised and records a pass or fail with details. Checks are the place for the rules a person would otherwise eyeball — no blank regions, revenue not negative, every expected region present:
EXPECTED = {"North", "South", "East"}
@dg.asset_check(asset=clean_sales, blocking=True, partitions_def=monthly)
def regions_complete(clean_sales: pd.DataFrame) -> dg.AssetCheckResult:
found = set(clean_sales["Region"])
missing = sorted(EXPECTED - found)
return dg.AssetCheckResult(passed=not missing, metadata={"missing": ", ".join(missing) or "none"})
@dg.asset_check(asset=clean_sales, blocking=True, partitions_def=monthly)
def no_negative_revenue(clean_sales: pd.DataFrame) -> dg.AssetCheckResult:
bad = int((clean_sales["Revenue"] < 0).sum())
return dg.AssetCheckResult(passed=bad == 0, metadata={"negative_rows": bad})
print([c.check_key.name for c in (regions_complete, no_negative_revenue)])
partitions_def=monthly makes each check run per month, against the same partition as the asset it checks — without it, a check loads every partition of the asset at once, and fails as soon as one month has not been materialised yet. Partitioned checks were marked as a preview feature in the Dagster version used here, so expect the API to settle in later releases. blocking=True means that if the check fails, assets downstream of clean_sales — the report workbook — are not materialised in that run. A broken export therefore stops before a wrong report reaches finance, and the failure, with its metadata, is visible in the UI and in alerts. Non-blocking checks still record the problem but let the run continue, which suits warnings such as an unusually low total. The same rules as plain code are discussed in Validate an Excel Report Before Sending It.
Step 3 — Definitions, a job and a schedule
Definitions gathers everything Dagster should load: assets, checks, resources, jobs and schedules. A job selects the assets to run, and a schedule built from a partitioned job runs the latest complete month:
report_job = dg.define_asset_job("monthly_sales_report", selection=dg.AssetSelection.groups("sales_report"))
report_schedule = dg.build_schedule_from_partitioned_job(report_job, hour_of_day=6, minute_of_hour=30)
defs = dg.Definitions(
assets=[raw_sales, clean_sales, sales_report_xlsx],
asset_checks=[regions_complete, no_negative_revenue],
jobs=[report_job],
schedules=[report_schedule],
resources={"paths": ReportPaths(inbox="inbox", outbox="outbox")},
)
schedule = defs.get_schedule_def(report_schedule.name)
print(schedule.name, "|", schedule.cron_schedule, "|", schedule.execution_timezone or "UTC")
The job's partitions come from the assets it selects, and Dagster resolves the schedule when the definitions are loaded — which is why the cron expression is read from defs rather than from the schedule object. For a monthly partition, build_schedule_from_partitioned_job produces a schedule that runs at 06:30 on the first day of each month and materialises the month that has just ended — on 1 October it runs the September partition. Set execution_timezone on the schedule if 06:30 should mean local time rather than UTC; see Schedule Reports Correctly Across Time Zones and DST. The resource is configured once here; in production its values can come from environment variables with dg.EnvVar("REPORT_OUTBOX"), so the same code runs against test and live folders.
Step 4 — Test the pipeline in-process
dg.materialize() runs assets in the current Python process without a Dagster instance or UI, which makes it ideal for tests. Materialise the September partition and inspect the results:
result = dg.materialize(
[raw_sales, clean_sales, sales_report_xlsx, regions_complete, no_negative_revenue],
partition_key="2026-09-01",
resources={"paths": ReportPaths()},
)
print("success:", result.success)
print("checks:", {e.check_name: e.passed for e in result.get_asset_check_evaluations()})
summary = pd.read_excel(result.output_for_node("sales_report_xlsx"), sheet_name="Summary")
print(summary.to_dict("records"))
The run succeeds, both checks pass, and the Summary sheet shows North with 495 — the stray "north " row was trimmed and merged into North by clean_sales. output_for_node returns an asset's value from the run, here the path of the written workbook. A pytest test can make the same calls against a small fixture workbook and assert on the summary; see Test Excel Output with pytest.
Step 5 — Re-run a past month
Partitions make backfills a first-class operation. Re-running August is the same call with a different partition key — in the UI it is a Backfill over a range of months:
august = dg.materialize(
[raw_sales, clean_sales, sales_report_xlsx, regions_complete, no_negative_revenue],
partition_key="2026-08-01",
resources={"paths": ReportPaths()},
)
meta = august.asset_materializations_for_node("sales_report_xlsx")[0].metadata
print(meta["report_file"].value, "| total:", meta["total"].value)
print(sorted(p.name for p in __import__("pathlib").Path("outbox").glob("*.xlsx")))
August's report is written to its own file with a total of 790 — East's missing revenue counted as zero by the cleaning rule. Each month is an independent partition, so re-running August never touches September's report. A blank revenue being treated as zero is exactly the kind of decision worth recording in the asset's docstring, which Dagster shows in the UI. Backfilling without an orchestrator is covered in Backfill Excel Reports for Past Dates.
Step 6 — Run it for real
Put the definitions in a module such as reports/definitions.py, then start the local UI and daemon:
dagster dev -m reports.definitions
The UI shows the asset graph, every materialisation with its metadata and check results, and lets you launch runs and backfills by month. The schedule only fires while the Dagster daemon is running — dagster dev starts one for local use; in production run the webserver and daemon as services, or use Dagster's hosted offering. Compared with Orchestrate Excel Reports with Prefect, which models tasks and flows, Dagster's asset model fits best when several reports share intermediate tables and you care about which version of each table a report was built from.
Common pitfalls
- Returning huge DataFrames between assets. The default I/O manager pickles them to disk; for big data, write files and pass paths.
- Non-blocking checks on critical rules. Use
blocking=Trueso a failed check stops the report. - Hard-coded folders. Use a resource, configured per environment.
- Expecting schedules without the daemon. Schedules run only while the daemon is up.
- Forgetting the partition key in file names. Each month should write its own file.
Conclusion
In Dagster, an Excel report becomes a chain of assets — source, cleaned data, workbook — whose dependencies come from function parameters. Monthly partitions give each period its own runs and make backfills trivial, blocking asset checks stop a bad report before it is written, a resource carries the folder configuration, and a schedule built from the partitioned job runs the latest month automatically. materialize() runs the whole pipeline in-process, so it can be tested like any other code.
Frequently asked questions
How do I run an Excel report with Dagster? Define assets with the asset decorator for each step — reading the workbook, cleaning, writing the report — collect them in Definitions with a job and schedule, and run dagster dev to start the UI and daemon.
How do I re-run a Dagster report for a previous month? Partition the assets with MonthlyPartitionsDefinition and materialise the earlier partition, either with a backfill in the UI or with materialize and partition_key in code.
How do I stop a Dagster pipeline when the data is wrong? Add asset checks with blocking set to True. If such a check fails, downstream assets are not materialised in that run.
How do I test Dagster assets? Call dagster's materialize function with the assets, a partition key and test resources. It runs in-process and returns a result with success, check evaluations and asset outputs.
Related
- Up one level: Scheduling Python Excel Scripts with Cron — running reports on a schedule.
- Orchestrate Excel Reports with Apache Airflow — the DAG-of-tasks approach.
- Choose a Scheduler for Python Excel Reports — when an orchestrator is worth it.
- Name, Version and Archive Generated Excel Reports — keeping the outputs organised.
More on scheduling & orchestration
- How-toDownload New Excel Files from an SFTP Server
- How-toRead Excel Attachments into a Database Nightly
- How-toRefresh an Excel Report from a Database on a Schedule
- How-toRun a Report When an Excel File Changes in OneDrive
- How-toSend Report Emails at Each Recipient's Local Time
- How-toDetect a Scheduled Report That Did Not Run