[{"data":1,"prerenderedAt":2130},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster":3,"surround:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster":2098,"ancestors:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster":2105,"children:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster":2110,"related:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster":2111},{"id":4,"title":5,"body":6,"dateModified":2065,"datePublished":2065,"description":2066,"extension":2067,"faq":2068,"keyword":2077,"kind":2078,"meta":2079,"navigation":144,"path":2088,"seo":2089,"slug":2092,"stem":2093,"topics":2094,"type":2096,"__hash__":2097},"docs\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster\u002Findex.md","Orchestrate Excel Reports with Dagster",{"type":7,"value":8,"toc":2052},"minimark",[9,29,34,69,92,96,103,750,779,893,897,904,1179,1200,1284,1288,1294,1485,1509,1513,1519,1639,1656,1660,1667,1794,1801,1889,1893,1900,1919,1935,1939,1976,1980,1987,1991,1997,2003,2009,2015,2019,2048],[10,11,12,13,17,18,22,23,28],"p",{},"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 ",[14,15,16],"em",{},"data"," each step produces rather than the tasks. You declare ",[19,20,21],"strong",{},"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 ",[24,25,27],"a",{"href":26},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","Scheduling Python Excel Scripts with Cron",".",[30,31,33],"h2",{"id":32},"prerequisites","Prerequisites",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install dagster dagster-webserver pandas openpyxl\n","bash","",[42,43,44],"code",{"__ignoreMap":40},[45,46,49,53,57,60,63,66],"span",{"class":47,"line":48},"line",1,[45,50,52],{"class":51},"sMTad","pip",[45,54,56],{"class":55},"srMev"," install",[45,58,59],{"class":55}," dagster",[45,61,62],{"class":55}," dagster-webserver",[45,64,65],{"class":55}," pandas",[45,67,68],{"class":55}," openpyxl\n",[10,70,71,72,75,76,79,80,83,84,87,88,91],{},"The source system drops one export per month into ",[42,73,74],{},"inbox\u002F"," — ",[42,77,78],{},"sales_2026-08.xlsx"," and ",[42,81,82],{},"sales_2026-09.xlsx",", with the usual problems: a region typed as ",[42,85,86],{},"\"north \"",", and a missing revenue in August. The examples were run with Dagster 1.13; ",[42,89,90],{},"dagster-webserver"," provides the UI and is only needed for Step 6.",[30,93,95],{"id":94},"step-1-describe-the-report-as-assets","Step 1 — Describe the report as assets",[10,97,98,99,102],{},"Each asset is a function decorated with ",[42,100,101],{},"@asset",". Its parameters name the assets it depends on, so the dependency graph comes from the function signatures:",[35,104,108],{"className":105,"code":106,"language":107,"meta":40,"style":40},"language-python shiki shiki-themes github-light github-dark-high-contrast","import dagster as dg\nimport pandas as pd\n\nmonthly = dg.MonthlyPartitionsDefinition(start_date=\"2026-08-01\")\n\nclass ReportPaths(dg.ConfigurableResource):\n    inbox: str = \"inbox\"\n    outbox: str = \"outbox\"\n\n@dg.asset(partitions_def=monthly, group_name=\"sales_report\")\ndef raw_sales(context: dg.AssetExecutionContext, paths: ReportPaths) -> pd.DataFrame:\n    \"\"\"The monthly export as delivered.\"\"\"\n    month = context.partition_key[:7]\n    df = pd.read_excel(f\"{paths.inbox}\u002Fsales_{month}.xlsx\")\n    context.add_output_metadata({\"rows\": len(df), \"source\": f\"sales_{month}.xlsx\"})\n    return df\n\n@dg.asset(partitions_def=monthly, group_name=\"sales_report\")\ndef clean_sales(raw_sales: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"Trimmed, title-cased regions; missing revenue treated as zero.\"\"\"\n    return raw_sales.assign(Region=raw_sales[\"Region\"].str.strip().str.title(),\n                            Revenue=raw_sales[\"Revenue\"].fillna(0.0))\n\n@dg.asset(partitions_def=monthly, group_name=\"sales_report\")\ndef sales_report_xlsx(context: dg.AssetExecutionContext, clean_sales: pd.DataFrame, paths: ReportPaths) -> str:\n    \"\"\"The workbook sent to finance.\"\"\"\n    from pathlib import Path\n    month = context.partition_key[:7]\n    Path(paths.outbox).mkdir(exist_ok=True)\n    out = f\"{paths.outbox}\u002Fsales-report_{month}.xlsx\"\n    summary = clean_sales.groupby(\"Region\", as_index=False)[\"Revenue\"].sum()\n    with pd.ExcelWriter(out) as xw:\n        summary.to_excel(xw, sheet_name=\"Summary\", index=False)\n        clean_sales.to_excel(xw, sheet_name=\"Detail\", index=False)\n    context.add_output_metadata({\"report_file\": out, \"total\": float(summary[\"Revenue\"].sum())})\n    return out\n\nprint([a.key.to_user_string() for a in (raw_sales, clean_sales, sales_report_xlsx)])\n","python",[42,109,110,126,139,146,170,175,199,215,228,233,260,272,278,295,337,376,385,390,411,422,428,450,472,477,498,514,520,534,547,563,595,627,641,666,689,716,724,729],{"__ignoreMap":40},[45,111,112,116,120,123],{"class":47,"line":48},[45,113,115],{"class":114},"s-kum","import",[45,117,119],{"class":118},"skGVy"," dagster ",[45,121,122],{"class":114},"as",[45,124,125],{"class":118}," dg\n",[45,127,129,131,134,136],{"class":47,"line":128},2,[45,130,115],{"class":114},[45,132,133],{"class":118}," pandas ",[45,135,122],{"class":114},[45,137,138],{"class":118}," pd\n",[45,140,142],{"class":47,"line":141},3,[45,143,145],{"emptyLinePlaceholder":144},true,"\n",[45,147,149,152,155,158,162,164,167],{"class":47,"line":148},4,[45,150,151],{"class":118},"monthly ",[45,153,154],{"class":114},"=",[45,156,157],{"class":118}," dg.MonthlyPartitionsDefinition(",[45,159,161],{"class":160},"sa561","start_date",[45,163,154],{"class":114},[45,165,166],{"class":55},"\"2026-08-01\"",[45,168,169],{"class":118},")\n",[45,171,173],{"class":47,"line":172},5,[45,174,145],{"emptyLinePlaceholder":144},[45,176,178,181,184,187,191,193,196],{"class":47,"line":177},6,[45,179,180],{"class":114},"class",[45,182,183],{"class":51}," ReportPaths",[45,185,186],{"class":118},"(",[45,188,190],{"class":189},"sHg5J","dg",[45,192,28],{"class":118},[45,194,195],{"class":189},"ConfigurableResource",[45,197,198],{"class":118},"):\n",[45,200,202,205,209,212],{"class":47,"line":201},7,[45,203,204],{"class":118},"    inbox: ",[45,206,208],{"class":207},"sP0c6","str",[45,210,211],{"class":114}," =",[45,213,214],{"class":55}," \"inbox\"\n",[45,216,218,221,223,225],{"class":47,"line":217},8,[45,219,220],{"class":118},"    outbox: ",[45,222,208],{"class":207},[45,224,211],{"class":114},[45,226,227],{"class":55}," \"outbox\"\n",[45,229,231],{"class":47,"line":230},9,[45,232,145],{"emptyLinePlaceholder":144},[45,234,236,240,242,245,247,250,253,255,258],{"class":47,"line":235},10,[45,237,239],{"class":238},"s_Opv","@dg.asset",[45,241,186],{"class":118},[45,243,244],{"class":160},"partitions_def",[45,246,154],{"class":114},[45,248,249],{"class":118},"monthly, ",[45,251,252],{"class":160},"group_name",[45,254,154],{"class":114},[45,256,257],{"class":55},"\"sales_report\"",[45,259,169],{"class":118},[45,261,263,266,269],{"class":47,"line":262},11,[45,264,265],{"class":114},"def",[45,267,268],{"class":238}," raw_sales",[45,270,271],{"class":118},"(context: dg.AssetExecutionContext, paths: ReportPaths) -> pd.DataFrame:\n",[45,273,275],{"class":47,"line":274},12,[45,276,277],{"class":55},"    \"\"\"The monthly export as delivered.\"\"\"\n",[45,279,281,284,286,289,292],{"class":47,"line":280},13,[45,282,283],{"class":118},"    month ",[45,285,154],{"class":114},[45,287,288],{"class":118}," context.partition_key[:",[45,290,291],{"class":207},"7",[45,293,294],{"class":118},"]\n",[45,296,298,301,303,306,309,312,316,319,322,325,327,330,332,335],{"class":47,"line":297},14,[45,299,300],{"class":118},"    df ",[45,302,154],{"class":114},[45,304,305],{"class":118}," pd.read_excel(",[45,307,308],{"class":114},"f",[45,310,311],{"class":55},"\"",[45,313,315],{"class":314},"sSjpA","{",[45,317,318],{"class":118},"paths.inbox",[45,320,321],{"class":314},"}",[45,323,324],{"class":55},"\u002Fsales_",[45,326,315],{"class":314},[45,328,329],{"class":118},"month",[45,331,321],{"class":314},[45,333,334],{"class":55},".xlsx\"",[45,336,169],{"class":118},[45,338,340,343,346,349,352,355,358,360,362,365,367,369,371,373],{"class":47,"line":339},15,[45,341,342],{"class":118},"    context.add_output_metadata({",[45,344,345],{"class":55},"\"rows\"",[45,347,348],{"class":118},": ",[45,350,351],{"class":207},"len",[45,353,354],{"class":118},"(df), ",[45,356,357],{"class":55},"\"source\"",[45,359,348],{"class":118},[45,361,308],{"class":114},[45,363,364],{"class":55},"\"sales_",[45,366,315],{"class":314},[45,368,329],{"class":118},[45,370,321],{"class":314},[45,372,334],{"class":55},[45,374,375],{"class":118},"})\n",[45,377,379,382],{"class":47,"line":378},16,[45,380,381],{"class":114},"    return",[45,383,384],{"class":118}," df\n",[45,386,388],{"class":47,"line":387},17,[45,389,145],{"emptyLinePlaceholder":144},[45,391,393,395,397,399,401,403,405,407,409],{"class":47,"line":392},18,[45,394,239],{"class":238},[45,396,186],{"class":118},[45,398,244],{"class":160},[45,400,154],{"class":114},[45,402,249],{"class":118},[45,404,252],{"class":160},[45,406,154],{"class":114},[45,408,257],{"class":55},[45,410,169],{"class":118},[45,412,414,416,419],{"class":47,"line":413},19,[45,415,265],{"class":114},[45,417,418],{"class":238}," clean_sales",[45,420,421],{"class":118},"(raw_sales: pd.DataFrame) -> pd.DataFrame:\n",[45,423,425],{"class":47,"line":424},20,[45,426,427],{"class":55},"    \"\"\"Trimmed, title-cased regions; missing revenue treated as zero.\"\"\"\n",[45,429,431,433,436,439,441,444,447],{"class":47,"line":430},21,[45,432,381],{"class":114},[45,434,435],{"class":118}," raw_sales.assign(",[45,437,438],{"class":160},"Region",[45,440,154],{"class":114},[45,442,443],{"class":118},"raw_sales[",[45,445,446],{"class":55},"\"Region\"",[45,448,449],{"class":118},"].str.strip().str.title(),\n",[45,451,453,456,458,460,463,466,469],{"class":47,"line":452},22,[45,454,455],{"class":160},"                            Revenue",[45,457,154],{"class":114},[45,459,443],{"class":118},[45,461,462],{"class":55},"\"Revenue\"",[45,464,465],{"class":118},"].fillna(",[45,467,468],{"class":207},"0.0",[45,470,471],{"class":118},"))\n",[45,473,475],{"class":47,"line":474},23,[45,476,145],{"emptyLinePlaceholder":144},[45,478,480,482,484,486,488,490,492,494,496],{"class":47,"line":479},24,[45,481,239],{"class":238},[45,483,186],{"class":118},[45,485,244],{"class":160},[45,487,154],{"class":114},[45,489,249],{"class":118},[45,491,252],{"class":160},[45,493,154],{"class":114},[45,495,257],{"class":55},[45,497,169],{"class":118},[45,499,501,503,506,509,511],{"class":47,"line":500},25,[45,502,265],{"class":114},[45,504,505],{"class":238}," sales_report_xlsx",[45,507,508],{"class":118},"(context: dg.AssetExecutionContext, clean_sales: pd.DataFrame, paths: ReportPaths) -> ",[45,510,208],{"class":207},[45,512,513],{"class":118},":\n",[45,515,517],{"class":47,"line":516},26,[45,518,519],{"class":55},"    \"\"\"The workbook sent to finance.\"\"\"\n",[45,521,523,526,529,531],{"class":47,"line":522},27,[45,524,525],{"class":114},"    from",[45,527,528],{"class":118}," pathlib ",[45,530,115],{"class":114},[45,532,533],{"class":118}," Path\n",[45,535,537,539,541,543,545],{"class":47,"line":536},28,[45,538,283],{"class":118},[45,540,154],{"class":114},[45,542,288],{"class":118},[45,544,291],{"class":207},[45,546,294],{"class":118},[45,548,550,553,556,558,561],{"class":47,"line":549},29,[45,551,552],{"class":118},"    Path(paths.outbox).mkdir(",[45,554,555],{"class":160},"exist_ok",[45,557,154],{"class":114},[45,559,560],{"class":207},"True",[45,562,169],{"class":118},[45,564,566,569,571,574,576,578,581,583,586,588,590,592],{"class":47,"line":565},30,[45,567,568],{"class":118},"    out ",[45,570,154],{"class":114},[45,572,573],{"class":114}," f",[45,575,311],{"class":55},[45,577,315],{"class":314},[45,579,580],{"class":118},"paths.outbox",[45,582,321],{"class":314},[45,584,585],{"class":55},"\u002Fsales-report_",[45,587,315],{"class":314},[45,589,329],{"class":118},[45,591,321],{"class":314},[45,593,594],{"class":55},".xlsx\"\n",[45,596,598,601,603,606,608,611,614,616,619,622,624],{"class":47,"line":597},31,[45,599,600],{"class":118},"    summary ",[45,602,154],{"class":114},[45,604,605],{"class":118}," clean_sales.groupby(",[45,607,446],{"class":55},[45,609,610],{"class":118},", ",[45,612,613],{"class":160},"as_index",[45,615,154],{"class":114},[45,617,618],{"class":207},"False",[45,620,621],{"class":118},")[",[45,623,462],{"class":55},[45,625,626],{"class":118},"].sum()\n",[45,628,630,633,636,638],{"class":47,"line":629},32,[45,631,632],{"class":114},"    with",[45,634,635],{"class":118}," pd.ExcelWriter(out) ",[45,637,122],{"class":114},[45,639,640],{"class":118}," xw:\n",[45,642,644,647,650,652,655,657,660,662,664],{"class":47,"line":643},33,[45,645,646],{"class":118},"        summary.to_excel(xw, ",[45,648,649],{"class":160},"sheet_name",[45,651,154],{"class":114},[45,653,654],{"class":55},"\"Summary\"",[45,656,610],{"class":118},[45,658,659],{"class":160},"index",[45,661,154],{"class":114},[45,663,618],{"class":207},[45,665,169],{"class":118},[45,667,669,672,674,676,679,681,683,685,687],{"class":47,"line":668},34,[45,670,671],{"class":118},"        clean_sales.to_excel(xw, ",[45,673,649],{"class":160},[45,675,154],{"class":114},[45,677,678],{"class":55},"\"Detail\"",[45,680,610],{"class":118},[45,682,659],{"class":160},[45,684,154],{"class":114},[45,686,618],{"class":207},[45,688,169],{"class":118},[45,690,692,694,697,700,703,705,708,711,713],{"class":47,"line":691},35,[45,693,342],{"class":118},[45,695,696],{"class":55},"\"report_file\"",[45,698,699],{"class":118},": out, ",[45,701,702],{"class":55},"\"total\"",[45,704,348],{"class":118},[45,706,707],{"class":207},"float",[45,709,710],{"class":118},"(summary[",[45,712,462],{"class":55},[45,714,715],{"class":118},"].sum())})\n",[45,717,719,721],{"class":47,"line":718},36,[45,720,381],{"class":114},[45,722,723],{"class":118}," out\n",[45,725,727],{"class":47,"line":726},37,[45,728,145],{"emptyLinePlaceholder":144},[45,730,732,735,738,741,744,747],{"class":47,"line":731},38,[45,733,734],{"class":207},"print",[45,736,737],{"class":118},"([a.key.to_user_string() ",[45,739,740],{"class":114},"for",[45,742,743],{"class":118}," a ",[45,745,746],{"class":114},"in",[45,748,749],{"class":118}," (raw_sales, clean_sales, sales_report_xlsx)])\n",[10,751,752,755,756,759,760,755,763,766,767,770,771,774,775,778],{},[42,753,754],{},"clean_sales(raw_sales)"," depends on ",[42,757,758],{},"raw_sales"," simply by naming it as a parameter, and ",[42,761,762],{},"sales_report_xlsx",[42,764,765],{},"clean_sales",". Each asset's return value is stored between steps by Dagster's I\u002FO manager (pickled to local disk by default), so a failed report step can be retried without re-reading the source. ",[42,768,769],{},"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 ",[42,772,773],{},"paths"," parameter is a ",[14,776,777],{},"resource",": configuration injected at run time, covered in Step 3.",[780,781,789,790,789,794,789,798,789,806,789,816,789,827,789,833,789,838,789,844,789,849,789,853,789,860,789,864,789,867,789,871,789,874,789,879,789,886,789,890],"svg",{"viewBox":782,"role":783,"ariaLabelledBy":784,"xmlns":787,"style":788},"4 0 752 125","img",[785,786],"dg-assets-t","dg-assets-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[791,792,793],"title",{"id":785},"The report as three assets",[795,796,797],"desc",{"id":786},"raw_sales reads the month's export, clean_sales trims and fixes it, and sales_report_xlsx writes the workbook. Each asset is partitioned by month and records metadata such as row count and total.",[799,800],"rect",{"x":801,"y":802,"width":803,"height":804,"fill":805},"4","0","752","125","#ffffff",[799,807],{"x":808,"y":809,"width":810,"height":811,"rx":812,"fill":813,"stroke":814,"style":815},"20.0","16.0","216.0","93.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[817,818,820],"g",{"transform":819},"translate(116.0,29.0) scale(1.000)",[821,822],"path",{"d":823,"fill":824,"stroke":825,"style":826},"M4 4h16v16H4z M4 9h16 M4 14h16 M10 4v16","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[828,829,758],"text",{"x":830,"y":831,"style":832},"128.0","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle",[828,834,837],{"x":830,"y":835,"style":836},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","read inbox\u002Fsales_YYYY-MM.xlsx",[47,839],{"x1":840,"y1":841,"x2":842,"y2":841,"stroke":843,"style":815},"241.0","62.5","263.0","var(--brand,#5b5cf0)",[845,846],"polygon",{"points":847,"fill":848},"267.0,62.5 258.0,67.5 258.0,57.5","#5b5cf0",[799,850],{"x":851,"y":809,"width":810,"height":811,"rx":812,"fill":852,"stroke":843,"style":815},"272.0","#f0f4ff",[817,854,856],{"transform":855},"translate(368.0,29.0) scale(1.000)",[821,857],{"d":858,"fill":824,"stroke":859,"style":826},"M3 5h18l-7 8v6l-4 2v-8z","var(--brand-strong,#4338ca)",[828,861,765],{"x":862,"y":831,"style":863},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[828,865,866],{"x":862,"y":835,"style":836},"trim, title-case, fill",[47,868],{"x1":869,"y1":841,"x2":870,"y2":841,"stroke":843,"style":815},"493.0","515.0",[845,872],{"points":873,"fill":848},"519.0,62.5 510.0,67.5 510.0,57.5",[799,875],{"x":876,"y":809,"width":810,"height":811,"rx":812,"fill":877,"stroke":878,"style":815},"524.0","#d9f4f1","var(--teal,#0f9488)",[817,880,882],{"transform":881},"translate(620.0,29.0) scale(1.000)",[821,883],{"d":884,"fill":824,"stroke":885,"style":826},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","var(--teal-ink,#0b6157)",[828,887,762],{"x":888,"y":831,"style":889},"632.0","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[828,891,892],{"x":888,"y":835,"style":836},"Summary + Detail sheets",[30,894,896],{"id":895},"step-2-validate-before-anyone-sees-the-report","Step 2 — Validate before anyone sees the report",[10,898,899,900,903],{},"An ",[19,901,902],{},"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:",[35,905,907],{"className":105,"code":906,"language":107,"meta":40,"style":40},"EXPECTED = {\"North\", \"South\", \"East\"}\n\n@dg.asset_check(asset=clean_sales, blocking=True, partitions_def=monthly)\ndef regions_complete(clean_sales: pd.DataFrame) -> dg.AssetCheckResult:\n    found = set(clean_sales[\"Region\"])\n    missing = sorted(EXPECTED - found)\n    return dg.AssetCheckResult(passed=not missing, metadata={\"missing\": \", \".join(missing) or \"none\"})\n\n@dg.asset_check(asset=clean_sales, blocking=True, partitions_def=monthly)\ndef no_negative_revenue(clean_sales: pd.DataFrame) -> dg.AssetCheckResult:\n    bad = int((clean_sales[\"Revenue\"] \u003C 0).sum())\n    return dg.AssetCheckResult(passed=bad == 0, metadata={\"negative_rows\": bad})\n\nprint([c.check_key.name for c in (regions_complete, no_negative_revenue)])\n",[42,908,909,935,939,970,980,998,1018,1060,1064,1090,1099,1126,1158,1162],{"__ignoreMap":40},[45,910,911,914,916,919,922,924,927,929,932],{"class":47,"line":48},[45,912,913],{"class":207},"EXPECTED",[45,915,211],{"class":114},[45,917,918],{"class":118}," {",[45,920,921],{"class":55},"\"North\"",[45,923,610],{"class":118},[45,925,926],{"class":55},"\"South\"",[45,928,610],{"class":118},[45,930,931],{"class":55},"\"East\"",[45,933,934],{"class":118},"}\n",[45,936,937],{"class":47,"line":128},[45,938,145],{"emptyLinePlaceholder":144},[45,940,941,944,946,949,951,954,957,959,961,963,965,967],{"class":47,"line":141},[45,942,943],{"class":238},"@dg.asset_check",[45,945,186],{"class":118},[45,947,948],{"class":160},"asset",[45,950,154],{"class":114},[45,952,953],{"class":118},"clean_sales, ",[45,955,956],{"class":160},"blocking",[45,958,154],{"class":114},[45,960,560],{"class":207},[45,962,610],{"class":118},[45,964,244],{"class":160},[45,966,154],{"class":114},[45,968,969],{"class":118},"monthly)\n",[45,971,972,974,977],{"class":47,"line":148},[45,973,265],{"class":114},[45,975,976],{"class":238}," regions_complete",[45,978,979],{"class":118},"(clean_sales: pd.DataFrame) -> dg.AssetCheckResult:\n",[45,981,982,985,987,990,993,995],{"class":47,"line":172},[45,983,984],{"class":118},"    found ",[45,986,154],{"class":114},[45,988,989],{"class":207}," set",[45,991,992],{"class":118},"(clean_sales[",[45,994,446],{"class":55},[45,996,997],{"class":118},"])\n",[45,999,1000,1003,1005,1008,1010,1012,1015],{"class":47,"line":177},[45,1001,1002],{"class":118},"    missing ",[45,1004,154],{"class":114},[45,1006,1007],{"class":207}," sorted",[45,1009,186],{"class":118},[45,1011,913],{"class":207},[45,1013,1014],{"class":114}," -",[45,1016,1017],{"class":118}," found)\n",[45,1019,1020,1022,1025,1028,1031,1034,1037,1039,1041,1044,1046,1049,1052,1055,1058],{"class":47,"line":201},[45,1021,381],{"class":114},[45,1023,1024],{"class":118}," dg.AssetCheckResult(",[45,1026,1027],{"class":160},"passed",[45,1029,1030],{"class":114},"=not",[45,1032,1033],{"class":118}," missing, ",[45,1035,1036],{"class":160},"metadata",[45,1038,154],{"class":114},[45,1040,315],{"class":118},[45,1042,1043],{"class":55},"\"missing\"",[45,1045,348],{"class":118},[45,1047,1048],{"class":55},"\", \"",[45,1050,1051],{"class":118},".join(missing) ",[45,1053,1054],{"class":114},"or",[45,1056,1057],{"class":55}," \"none\"",[45,1059,375],{"class":118},[45,1061,1062],{"class":47,"line":217},[45,1063,145],{"emptyLinePlaceholder":144},[45,1065,1066,1068,1070,1072,1074,1076,1078,1080,1082,1084,1086,1088],{"class":47,"line":230},[45,1067,943],{"class":238},[45,1069,186],{"class":118},[45,1071,948],{"class":160},[45,1073,154],{"class":114},[45,1075,953],{"class":118},[45,1077,956],{"class":160},[45,1079,154],{"class":114},[45,1081,560],{"class":207},[45,1083,610],{"class":118},[45,1085,244],{"class":160},[45,1087,154],{"class":114},[45,1089,969],{"class":118},[45,1091,1092,1094,1097],{"class":47,"line":235},[45,1093,265],{"class":114},[45,1095,1096],{"class":238}," no_negative_revenue",[45,1098,979],{"class":118},[45,1100,1101,1104,1106,1109,1112,1114,1117,1120,1123],{"class":47,"line":262},[45,1102,1103],{"class":118},"    bad ",[45,1105,154],{"class":114},[45,1107,1108],{"class":207}," int",[45,1110,1111],{"class":118},"((clean_sales[",[45,1113,462],{"class":55},[45,1115,1116],{"class":118},"] ",[45,1118,1119],{"class":114},"\u003C",[45,1121,1122],{"class":207}," 0",[45,1124,1125],{"class":118},").sum())\n",[45,1127,1128,1130,1132,1134,1136,1139,1142,1144,1146,1148,1150,1152,1155],{"class":47,"line":274},[45,1129,381],{"class":114},[45,1131,1024],{"class":118},[45,1133,1027],{"class":160},[45,1135,154],{"class":114},[45,1137,1138],{"class":118},"bad ",[45,1140,1141],{"class":114},"==",[45,1143,1122],{"class":207},[45,1145,610],{"class":118},[45,1147,1036],{"class":160},[45,1149,154],{"class":114},[45,1151,315],{"class":118},[45,1153,1154],{"class":55},"\"negative_rows\"",[45,1156,1157],{"class":118},": bad})\n",[45,1159,1160],{"class":47,"line":280},[45,1161,145],{"emptyLinePlaceholder":144},[45,1163,1164,1166,1169,1171,1174,1176],{"class":47,"line":297},[45,1165,734],{"class":207},[45,1167,1168],{"class":118},"([c.check_key.name ",[45,1170,740],{"class":114},[45,1172,1173],{"class":118}," c ",[45,1175,746],{"class":114},[45,1177,1178],{"class":118}," (regions_complete, no_negative_revenue)])\n",[10,1180,1181,1184,1185,1188,1189,1192,1193,1195,1196,28],{},[42,1182,1183],{},"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 ",[14,1186,1187],{},"preview"," feature in the Dagster version used here, so expect the API to settle in later releases. ",[42,1190,1191],{},"blocking=True"," means that if the check fails, assets downstream of ",[42,1194,765],{}," — 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 ",[24,1197,1199],{"href":1198},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002F","Validate an Excel Report Before Sending It",[780,1201,789,1206,789,1209,789,1212,789,1215,789,1222,789,1230,789,1235,789,1241,789,1246,789,1250,789,1254,789,1259,789,1266,789,1271,789,1275,789,1278,789,1281],{"viewBox":1202,"role":783,"ariaLabelledBy":1203,"xmlns":787,"style":788},"4 0 752 207",[1204,1205],"dg-checks-t","dg-checks-d",[791,1207,1208],{"id":1204},"Blocking and non-blocking checks",[795,1210,1211],{"id":1205},"A blocking check that fails stops downstream assets, so the report workbook is not written in that run. A non-blocking check records the failure and its metadata but lets the run continue, which suits warnings.",[799,1213],{"x":801,"y":802,"width":803,"height":1214,"fill":805},"207",[799,1216],{"x":808,"y":809,"width":1217,"height":1218,"rx":1219,"fill":1220,"stroke":1221,"style":815},"351.0","175.0","14","#fce9e9","var(--danger,#dc2626)",[817,1223,1225],{"transform":1224},"translate(182.5,29.0) scale(1.083)",[821,1226],{"d":1227,"fill":824,"stroke":1228,"style":1229},"M6 11h12v9H6z M8.5 11V8a3.5 3.5 0 0 1 7 0v3","var(--accent-ink,#be185d)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[828,1231,1191],{"x":1232,"y":1233,"style":1234},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle",[47,1236],{"x1":1237,"y1":1238,"x2":1239,"y2":1238,"stroke":1221,"style":1240},"36.0","91.0","355.0","stroke-width:1px",[828,1242,1245],{"x":1232,"y":1243,"style":1244},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","failure stops downstream assets",[828,1247,1249],{"x":1232,"y":1248,"style":1244},"137.0","no report written that run",[828,1251,1253],{"x":1232,"y":1252,"style":1244},"163.0","for rules that must hold",[799,1255],{"x":1256,"y":809,"width":1217,"height":1218,"rx":1219,"fill":1257,"stroke":1258,"style":815},"389.0","#fdefd8","var(--gold,#b4740a)",[817,1260,1262],{"transform":1261},"translate(551.5,29.0) scale(1.083)",[821,1263],{"d":1264,"fill":824,"stroke":1265,"style":1229},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4","var(--gold-ink,#7a4e06)",[828,1267,1270],{"x":1268,"y":1233,"style":1269},"564.5","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","blocking=False",[47,1272],{"x1":1273,"y1":1238,"x2":1274,"y2":1238,"stroke":1258,"style":1240},"405.0","724.0",[828,1276,1277],{"x":1268,"y":1243,"style":1244},"failure recorded with metadata",[828,1279,1280],{"x":1268,"y":1248,"style":1244},"run continues",[828,1282,1283],{"x":1268,"y":1252,"style":1244},"for warnings, e.g. low totals",[30,1285,1287],{"id":1286},"step-3-definitions-a-job-and-a-schedule","Step 3 — Definitions, a job and a schedule",[10,1289,1290,1293],{},[42,1291,1292],{},"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:",[35,1295,1297],{"className":105,"code":1296,"language":107,"meta":40,"style":40},"report_job = dg.define_asset_job(\"monthly_sales_report\", selection=dg.AssetSelection.groups(\"sales_report\"))\nreport_schedule = dg.build_schedule_from_partitioned_job(report_job, hour_of_day=6, minute_of_hour=30)\n\ndefs = dg.Definitions(\n    assets=[raw_sales, clean_sales, sales_report_xlsx],\n    asset_checks=[regions_complete, no_negative_revenue],\n    jobs=[report_job],\n    schedules=[report_schedule],\n    resources={\"paths\": ReportPaths(inbox=\"inbox\", outbox=\"outbox\")},\n)\nschedule = defs.get_schedule_def(report_schedule.name)\nprint(schedule.name, \"|\", schedule.cron_schedule, \"|\", schedule.execution_timezone or \"UTC\")\n",[42,1298,1299,1326,1356,1360,1370,1380,1390,1400,1410,1446,1450,1460],{"__ignoreMap":40},[45,1300,1301,1304,1306,1309,1312,1314,1317,1319,1322,1324],{"class":47,"line":48},[45,1302,1303],{"class":118},"report_job ",[45,1305,154],{"class":114},[45,1307,1308],{"class":118}," dg.define_asset_job(",[45,1310,1311],{"class":55},"\"monthly_sales_report\"",[45,1313,610],{"class":118},[45,1315,1316],{"class":160},"selection",[45,1318,154],{"class":114},[45,1320,1321],{"class":118},"dg.AssetSelection.groups(",[45,1323,257],{"class":55},[45,1325,471],{"class":118},[45,1327,1328,1331,1333,1336,1339,1341,1344,1346,1349,1351,1354],{"class":47,"line":128},[45,1329,1330],{"class":118},"report_schedule ",[45,1332,154],{"class":114},[45,1334,1335],{"class":118}," dg.build_schedule_from_partitioned_job(report_job, ",[45,1337,1338],{"class":160},"hour_of_day",[45,1340,154],{"class":114},[45,1342,1343],{"class":207},"6",[45,1345,610],{"class":118},[45,1347,1348],{"class":160},"minute_of_hour",[45,1350,154],{"class":114},[45,1352,1353],{"class":207},"30",[45,1355,169],{"class":118},[45,1357,1358],{"class":47,"line":141},[45,1359,145],{"emptyLinePlaceholder":144},[45,1361,1362,1365,1367],{"class":47,"line":148},[45,1363,1364],{"class":118},"defs ",[45,1366,154],{"class":114},[45,1368,1369],{"class":118}," dg.Definitions(\n",[45,1371,1372,1375,1377],{"class":47,"line":172},[45,1373,1374],{"class":160},"    assets",[45,1376,154],{"class":114},[45,1378,1379],{"class":118},"[raw_sales, clean_sales, sales_report_xlsx],\n",[45,1381,1382,1385,1387],{"class":47,"line":177},[45,1383,1384],{"class":160},"    asset_checks",[45,1386,154],{"class":114},[45,1388,1389],{"class":118},"[regions_complete, no_negative_revenue],\n",[45,1391,1392,1395,1397],{"class":47,"line":201},[45,1393,1394],{"class":160},"    jobs",[45,1396,154],{"class":114},[45,1398,1399],{"class":118},"[report_job],\n",[45,1401,1402,1405,1407],{"class":47,"line":217},[45,1403,1404],{"class":160},"    schedules",[45,1406,154],{"class":114},[45,1408,1409],{"class":118},"[report_schedule],\n",[45,1411,1412,1415,1417,1419,1422,1425,1428,1430,1433,1435,1438,1440,1443],{"class":47,"line":230},[45,1413,1414],{"class":160},"    resources",[45,1416,154],{"class":114},[45,1418,315],{"class":118},[45,1420,1421],{"class":55},"\"paths\"",[45,1423,1424],{"class":118},": ReportPaths(",[45,1426,1427],{"class":160},"inbox",[45,1429,154],{"class":114},[45,1431,1432],{"class":55},"\"inbox\"",[45,1434,610],{"class":118},[45,1436,1437],{"class":160},"outbox",[45,1439,154],{"class":114},[45,1441,1442],{"class":55},"\"outbox\"",[45,1444,1445],{"class":118},")},\n",[45,1447,1448],{"class":47,"line":235},[45,1449,169],{"class":118},[45,1451,1452,1455,1457],{"class":47,"line":262},[45,1453,1454],{"class":118},"schedule ",[45,1456,154],{"class":114},[45,1458,1459],{"class":118}," defs.get_schedule_def(report_schedule.name)\n",[45,1461,1462,1464,1467,1470,1473,1475,1478,1480,1483],{"class":47,"line":274},[45,1463,734],{"class":207},[45,1465,1466],{"class":118},"(schedule.name, ",[45,1468,1469],{"class":55},"\"|\"",[45,1471,1472],{"class":118},", schedule.cron_schedule, ",[45,1474,1469],{"class":55},[45,1476,1477],{"class":118},", schedule.execution_timezone ",[45,1479,1054],{"class":114},[45,1481,1482],{"class":55}," \"UTC\"",[45,1484,169],{"class":118},[10,1486,1487,1488,1491,1492,1495,1496,1499,1500,1504,1505,1508],{},"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 ",[42,1489,1490],{},"defs"," rather than from the schedule object. For a monthly partition, ",[42,1493,1494],{},"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 ",[42,1497,1498],{},"execution_timezone"," on the schedule if 06:30 should mean local time rather than UTC; see ",[24,1501,1503],{"href":1502},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst\u002F","Schedule Reports Correctly Across Time Zones and DST",". The resource is configured once here; in production its values can come from environment variables with ",[42,1506,1507],{},"dg.EnvVar(\"REPORT_OUTBOX\")",", so the same code runs against test and live folders.",[30,1510,1512],{"id":1511},"step-4-test-the-pipeline-in-process","Step 4 — Test the pipeline in-process",[10,1514,1515,1518],{},[42,1516,1517],{},"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:",[35,1520,1522],{"className":105,"code":1521,"language":107,"meta":40,"style":40},"result = dg.materialize(\n    [raw_sales, clean_sales, sales_report_xlsx, regions_complete, no_negative_revenue],\n    partition_key=\"2026-09-01\",\n    resources={\"paths\": ReportPaths()},\n)\nprint(\"success:\", result.success)\nprint(\"checks:\", {e.check_name: e.passed for e in result.get_asset_check_evaluations()})\nsummary = pd.read_excel(result.output_for_node(\"sales_report_xlsx\"), sheet_name=\"Summary\")\nprint(summary.to_dict(\"records\"))\n",[42,1523,1524,1534,1539,1552,1565,1569,1581,1603,1627],{"__ignoreMap":40},[45,1525,1526,1529,1531],{"class":47,"line":48},[45,1527,1528],{"class":118},"result ",[45,1530,154],{"class":114},[45,1532,1533],{"class":118}," dg.materialize(\n",[45,1535,1536],{"class":47,"line":128},[45,1537,1538],{"class":118},"    [raw_sales, clean_sales, sales_report_xlsx, regions_complete, no_negative_revenue],\n",[45,1540,1541,1544,1546,1549],{"class":47,"line":141},[45,1542,1543],{"class":160},"    partition_key",[45,1545,154],{"class":114},[45,1547,1548],{"class":55},"\"2026-09-01\"",[45,1550,1551],{"class":118},",\n",[45,1553,1554,1556,1558,1560,1562],{"class":47,"line":148},[45,1555,1414],{"class":160},[45,1557,154],{"class":114},[45,1559,315],{"class":118},[45,1561,1421],{"class":55},[45,1563,1564],{"class":118},": ReportPaths()},\n",[45,1566,1567],{"class":47,"line":172},[45,1568,169],{"class":118},[45,1570,1571,1573,1575,1578],{"class":47,"line":177},[45,1572,734],{"class":207},[45,1574,186],{"class":118},[45,1576,1577],{"class":55},"\"success:\"",[45,1579,1580],{"class":118},", result.success)\n",[45,1582,1583,1585,1587,1590,1593,1595,1598,1600],{"class":47,"line":201},[45,1584,734],{"class":207},[45,1586,186],{"class":118},[45,1588,1589],{"class":55},"\"checks:\"",[45,1591,1592],{"class":118},", {e.check_name: e.passed ",[45,1594,740],{"class":114},[45,1596,1597],{"class":118}," e ",[45,1599,746],{"class":114},[45,1601,1602],{"class":118}," result.get_asset_check_evaluations()})\n",[45,1604,1605,1608,1610,1613,1616,1619,1621,1623,1625],{"class":47,"line":217},[45,1606,1607],{"class":118},"summary ",[45,1609,154],{"class":114},[45,1611,1612],{"class":118}," pd.read_excel(result.output_for_node(",[45,1614,1615],{"class":55},"\"sales_report_xlsx\"",[45,1617,1618],{"class":118},"), ",[45,1620,649],{"class":160},[45,1622,154],{"class":114},[45,1624,654],{"class":55},[45,1626,169],{"class":118},[45,1628,1629,1631,1634,1637],{"class":47,"line":230},[45,1630,734],{"class":207},[45,1632,1633],{"class":118},"(summary.to_dict(",[45,1635,1636],{"class":55},"\"records\"",[45,1638,471],{"class":118},[10,1640,1641,1642,1644,1645,1647,1648,1651,1652,28],{},"The run succeeds, both checks pass, and the Summary sheet shows North with 495 — the stray ",[42,1643,86],{}," row was trimmed and merged into North by ",[42,1646,765],{},". ",[42,1649,1650],{},"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 ",[24,1653,1655],{"href":1654},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-output-with-pytest\u002F","Test Excel Output with pytest",[30,1657,1659],{"id":1658},"step-5-re-run-a-past-month","Step 5 — Re-run a past month",[10,1661,1662,1663,1666],{},"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 ",[14,1664,1665],{},"Backfill"," over a range of months:",[35,1668,1670],{"className":105,"code":1669,"language":107,"meta":40,"style":40},"august = dg.materialize(\n    [raw_sales, clean_sales, sales_report_xlsx, regions_complete, no_negative_revenue],\n    partition_key=\"2026-08-01\",\n    resources={\"paths\": ReportPaths()},\n)\nmeta = august.asset_materializations_for_node(\"sales_report_xlsx\")[0].metadata\nprint(meta[\"report_file\"].value, \"| total:\", meta[\"total\"].value)\nprint(sorted(p.name for p in __import__(\"pathlib\").Path(\"outbox\").glob(\"*.xlsx\")))\n",[42,1671,1672,1681,1685,1695,1707,1711,1730,1753],{"__ignoreMap":40},[45,1673,1674,1677,1679],{"class":47,"line":48},[45,1675,1676],{"class":118},"august ",[45,1678,154],{"class":114},[45,1680,1533],{"class":118},[45,1682,1683],{"class":47,"line":128},[45,1684,1538],{"class":118},[45,1686,1687,1689,1691,1693],{"class":47,"line":141},[45,1688,1543],{"class":160},[45,1690,154],{"class":114},[45,1692,166],{"class":55},[45,1694,1551],{"class":118},[45,1696,1697,1699,1701,1703,1705],{"class":47,"line":148},[45,1698,1414],{"class":160},[45,1700,154],{"class":114},[45,1702,315],{"class":118},[45,1704,1421],{"class":55},[45,1706,1564],{"class":118},[45,1708,1709],{"class":47,"line":172},[45,1710,169],{"class":118},[45,1712,1713,1716,1718,1721,1723,1725,1727],{"class":47,"line":177},[45,1714,1715],{"class":118},"meta ",[45,1717,154],{"class":114},[45,1719,1720],{"class":118}," august.asset_materializations_for_node(",[45,1722,1615],{"class":55},[45,1724,621],{"class":118},[45,1726,802],{"class":207},[45,1728,1729],{"class":118},"].metadata\n",[45,1731,1732,1734,1737,1739,1742,1745,1748,1750],{"class":47,"line":201},[45,1733,734],{"class":207},[45,1735,1736],{"class":118},"(meta[",[45,1738,696],{"class":55},[45,1740,1741],{"class":118},"].value, ",[45,1743,1744],{"class":55},"\"| total:\"",[45,1746,1747],{"class":118},", meta[",[45,1749,702],{"class":55},[45,1751,1752],{"class":118},"].value)\n",[45,1754,1755,1757,1759,1762,1765,1767,1770,1772,1775,1777,1780,1783,1785,1788,1791],{"class":47,"line":217},[45,1756,734],{"class":207},[45,1758,186],{"class":118},[45,1760,1761],{"class":207},"sorted",[45,1763,1764],{"class":118},"(p.name ",[45,1766,740],{"class":114},[45,1768,1769],{"class":118}," p ",[45,1771,746],{"class":114},[45,1773,1774],{"class":207}," __import__",[45,1776,186],{"class":118},[45,1778,1779],{"class":55},"\"pathlib\"",[45,1781,1782],{"class":118},").Path(",[45,1784,1442],{"class":55},[45,1786,1787],{"class":118},").glob(",[45,1789,1790],{"class":55},"\"*.xlsx\"",[45,1792,1793],{"class":118},")))\n",[10,1795,1796,1797,28],{},"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 ",[24,1798,1800],{"href":1799},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fbackfill-excel-reports-for-past-dates\u002F","Backfill Excel Reports for Past Dates",[780,1802,789,1807,789,1810,789,1813,789,1816,789,1820,789,1825,789,1831,789,1834,789,1838,789,1842,789,1846,789,1848,789,1852,789,1854,789,1857,789,1860,789,1863,789,1865,789,1868,789,1870,789,1872,789,1875,789,1878,789,1880,789,1884,789,1886],{"viewBox":1803,"role":783,"ariaLabelledBy":1804,"xmlns":787,"style":788},"4 0 752 174",[1805,1806],"dg-partitions-t","dg-partitions-d",[791,1808,1809],{"id":1805},"Each month is its own partition",[795,1811,1812],{"id":1806},"A grid of assets against monthly partitions. For August and September, raw_sales, clean_sales and the report workbook are all materialised, and both checks passed. Re-running one month leaves the other untouched.",[799,1814],{"x":801,"y":802,"width":803,"height":1815,"fill":805},"174",[799,1817],{"x":808,"y":809,"width":1818,"height":1819,"rx":802,"fill":805,"stroke":814,"style":1240},"140.0","40.0",[799,1821],{"x":1822,"y":809,"width":1823,"height":1819,"rx":802,"fill":852,"stroke":843,"style":1824},"160.0","290.0","stroke-width:1.2px",[828,1826,1830],{"x":1827,"y":1828,"style":1829},"305.0","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","2026-08",[799,1832],{"x":1833,"y":809,"width":1823,"height":1819,"rx":802,"fill":852,"stroke":843,"style":1824},"450.0",[828,1835,1837],{"x":1836,"y":1828,"style":1829},"595.0","2026-09",[799,1839],{"x":808,"y":1840,"width":1818,"height":1841,"rx":802,"fill":813,"stroke":814,"style":1240},"56.0","34.0",[828,1843,758],{"x":1844,"y":1233,"style":1845},"32.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start",[799,1847],{"x":1822,"y":1840,"width":1823,"height":1841,"rx":802,"fill":805,"stroke":814,"style":1240},[828,1849,1851],{"x":1827,"y":1850,"style":836},"77.0","✓ 3 rows",[799,1853],{"x":1833,"y":1840,"width":1823,"height":1841,"rx":802,"fill":805,"stroke":814,"style":1240},[828,1855,1856],{"x":1836,"y":1850,"style":836},"✓ 4 rows",[799,1858],{"x":808,"y":1859,"width":1818,"height":1841,"rx":802,"fill":813,"stroke":814,"style":1240},"90.0",[828,1861,765],{"x":1844,"y":1862,"style":1845},"112.0",[799,1864],{"x":1822,"y":1859,"width":1823,"height":1841,"rx":802,"fill":805,"stroke":814,"style":1240},[828,1866,1867],{"x":1827,"y":1243,"style":836},"✓ checks pass",[799,1869],{"x":1833,"y":1859,"width":1823,"height":1841,"rx":802,"fill":805,"stroke":814,"style":1240},[828,1871,1867],{"x":1836,"y":1243,"style":836},[799,1873],{"x":808,"y":1874,"width":1818,"height":1841,"rx":802,"fill":813,"stroke":814,"style":1240},"124.0",[828,1876,762],{"x":1844,"y":1877,"style":1845},"146.0",[799,1879],{"x":1822,"y":1874,"width":1823,"height":1841,"rx":802,"fill":805,"stroke":814,"style":1240},[828,1881,1883],{"x":1827,"y":1882,"style":836},"145.0","✓ total 790",[799,1885],{"x":1833,"y":1874,"width":1823,"height":1841,"rx":802,"fill":805,"stroke":814,"style":1240},[828,1887,1888],{"x":1836,"y":1882,"style":836},"✓ total 1,158",[30,1890,1892],{"id":1891},"step-6-run-it-for-real","Step 6 — Run it for real",[10,1894,1895,1896,1899],{},"Put the definitions in a module such as ",[42,1897,1898],{},"reports\u002Fdefinitions.py",", then start the local UI and daemon:",[35,1901,1903],{"className":37,"code":1902,"language":39,"meta":40,"style":40},"dagster dev -m reports.definitions\n",[42,1904,1905],{"__ignoreMap":40},[45,1906,1907,1910,1913,1916],{"class":47,"line":48},[45,1908,1909],{"class":51},"dagster",[45,1911,1912],{"class":55}," dev",[45,1914,1915],{"class":207}," -m",[45,1917,1918],{"class":55}," reports.definitions\n",[10,1920,1921,1922,1925,1926,1930,1931,1934],{},"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 — ",[42,1923,1924],{},"dagster dev"," starts one for local use; in production run the webserver and daemon as services, or use Dagster's hosted offering. Compared with ",[24,1927,1929],{"href":1928},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-prefect\u002F","Orchestrate Excel Reports with Prefect",", which models ",[14,1932,1933],{},"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.",[30,1936,1938],{"id":1937},"common-pitfalls","Common pitfalls",[1940,1941,1942,1949,1958,1964,1970],"ul",{},[1943,1944,1945,1948],"li",{},[19,1946,1947],{},"Returning huge DataFrames between assets."," The default I\u002FO manager pickles them to disk; for big data, write files and pass paths.",[1943,1950,1951,1954,1955,1957],{},[19,1952,1953],{},"Non-blocking checks on critical rules."," Use ",[42,1956,1191],{}," so a failed check stops the report.",[1943,1959,1960,1963],{},[19,1961,1962],{},"Hard-coded folders."," Use a resource, configured per environment.",[1943,1965,1966,1969],{},[19,1967,1968],{},"Expecting schedules without the daemon."," Schedules run only while the daemon is up.",[1943,1971,1972,1975],{},[19,1973,1974],{},"Forgetting the partition key in file names."," Each month should write its own file.",[30,1977,1979],{"id":1978},"conclusion","Conclusion",[10,1981,1982,1983,1986],{},"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. ",[42,1984,1985],{},"materialize()"," runs the whole pipeline in-process, so it can be tested like any other code.",[30,1988,1990],{"id":1989},"frequently-asked-questions","Frequently asked questions",[10,1992,1993,1996],{},[19,1994,1995],{},"How do I run an Excel report with Dagster?","\nDefine 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.",[10,1998,1999,2002],{},[19,2000,2001],{},"How do I re-run a Dagster report for a previous month?","\nPartition the assets with MonthlyPartitionsDefinition and materialise the earlier partition, either with a backfill in the UI or with materialize and partition_key in code.",[10,2004,2005,2008],{},[19,2006,2007],{},"How do I stop a Dagster pipeline when the data is wrong?","\nAdd asset checks with blocking set to True. If such a check fails, downstream assets are not materialised in that run.",[10,2010,2011,2014],{},[19,2012,2013],{},"How do I test Dagster assets?","\nCall 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.",[30,2016,2018],{"id":2017},"related","Related",[1940,2020,2021,2027,2034,2041],{},[1943,2022,2023,2024,2026],{},"Up one level: ",[24,2025,27],{"href":26}," — running reports on a schedule.",[1943,2028,2029,2033],{},[24,2030,2032],{"href":2031},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-apache-airflow\u002F","Orchestrate Excel Reports with Apache Airflow"," — the DAG-of-tasks approach.",[1943,2035,2036,2040],{},[24,2037,2039],{"href":2038},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fchoose-a-scheduler-for-python-excel-reports\u002F","Choose a Scheduler for Python Excel Reports"," — when an orchestrator is worth it.",[1943,2042,2043,2047],{},[24,2044,2046],{"href":2045},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fname-version-and-archive-generated-excel-reports\u002F","Name, Version and Archive Generated Excel Reports"," — keeping the outputs organised.",[2049,2050,2051],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sHg5J, html code.shiki .sHg5J{--shiki-default:#6F42C1;--shiki-dark:#91CBFF}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":40,"searchDepth":128,"depth":128,"links":2053},[2054,2055,2056,2057,2058,2059,2060,2061,2062,2063,2064],{"id":32,"depth":128,"text":33},{"id":94,"depth":128,"text":95},{"id":895,"depth":128,"text":896},{"id":1286,"depth":128,"text":1287},{"id":1511,"depth":128,"text":1512},{"id":1658,"depth":128,"text":1659},{"id":1891,"depth":128,"text":1892},{"id":1937,"depth":128,"text":1938},{"id":1978,"depth":128,"text":1979},{"id":1989,"depth":128,"text":1990},{"id":2017,"depth":128,"text":2018},"2026-10-02","Run an Excel reporting pipeline as Dagster software-defined assets: read the source workbook, clean it and write the report as separate assets, pass the output folder in as a resource, partition by month for backfills, validate with asset checks, schedule the job, and test it all in-process with materialize.","md",[2069,2071,2073,2075],{"q":1995,"a":2070},"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.",{"q":2001,"a":2072},"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.",{"q":2007,"a":2074},"Add asset checks with blocking set to True. If such a check fails, downstream assets are not materialised in that run.",{"q":2013,"a":2076},"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.","orchestrate excel reports with dagster",null,{"breadcrumb":2080},[2081,2084,2087],{"name":2082,"item":2083},"Home","\u002F",{"name":2085,"item":2086},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":27,"item":26},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster",{"title":2090,"description":2091},"Orchestrate Excel Reports with Dagster in Python","Model an Excel report as Dagster assets: source, cleaned data and report workbook, a ConfigurableResource for paths, monthly partitions and backfills, asset checks, a schedule and in-process tests with materialize.","orchestrate-excel-reports-with-dagster","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-dagster\u002Findex",[2095],"scheduling","how-to","y_a9eryUcm8bFFzTSjTJUvLqAfES9wRCR-zFR8SYb4w",[2099,2102],{"title":2032,"path":2100,"stem":2101,"children":-1},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-apache-airflow","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-apache-airflow\u002Findex",{"title":1929,"path":2103,"stem":2104,"children":-1},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-prefect","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Forchestrate-excel-reports-with-prefect\u002Findex",[2106,2108],{"path":2107,"title":2085},"\u002Fautomating-reporting-workflows",{"path":2109,"title":27},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron",[],[2112,2115,2118,2121,2124,2127],{"path":2113,"title":2114,"kind":2096},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fdownload-new-excel-files-from-an-sftp-server","Download New Excel Files from an SFTP Server",{"path":2116,"title":2117,"kind":2096},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fread-excel-attachments-into-a-database-nightly","Read Excel Attachments into a Database Nightly",{"path":2119,"title":2120,"kind":2096},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule","Refresh an Excel Report from a Database on a Schedule",{"path":2122,"title":2123,"kind":2096},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frun-a-report-when-an-excel-file-changes-in-onedrive","Run a Report When an Excel File Changes in OneDrive",{"path":2125,"title":2126,"kind":2096},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time","Send Report Emails at Each Recipient's Local Time",{"path":2128,"title":2129,"kind":2096},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",1790972138692]