How-to
Automating Reporting WorkflowsDeep dive

Run an Excel Report in Azure Functions

Schedule a Python Excel report as an Azure Function: a timer trigger with a six-field NCRONTAB schedule, the workbook built in memory and written to Blob Storage through an output binding, a retry policy, time zones and execution time limits, local testing of the handler and its bindings, and deployment with Core Tools.

For organisations on Microsoft Azure, a timer-triggered Azure Function is the lightweight way to run a report on a schedule: no virtual machine to patch, no cron to maintain, billing per execution, and the output written straight into a Storage account where Power BI, Logic Apps or SharePoint can pick it up. The Python v2 programming model keeps everything in one file, function_app.py, with decorators declaring the trigger and the output. This page, part of Scheduling Python Excel Scripts with Cron, builds a weekly sales report as a function, tests it without Azure, and covers the limits that decide whether a report fits in a function at all.

Prerequisites

Bash
pip install azure-functions pandas openpyxl croniter
# to run and deploy: Azure Functions Core Tools and the Azure CLI

A function app project is a folder with function_app.py, requirements.txt listing the packages the function needs, and host.json. local.settings.json holds settings for local runs, including AzureWebJobsStorage, the storage connection the function uses for its own bookkeeping and — here — for the report output. sales_source.csv stands in for the database the real report queries.

One scheduled execution The Functions host fires the timer trigger on the NCRONTAB schedule, the handler reads the source data and builds the workbook in memory, returns the bytes to the blob output binding, and the host writes them to a dated blob. Timer fires 0 0 7 * * 1 (UTC) Read data source query Build workbook in memory Output binding report.set(bytes) Dated blob one .xlsx per run

Step 1 — Write the function

The report logic lives in an ordinary function; the Azure-specific part is the decorated handler that calls it. The timer trigger declares the schedule and the blob output binding declares where the bytes go:

Python
import io
import logging

import azure.functions as func
import pandas as pd

app = func.FunctionApp()

def build_report() -> bytes:
    df = pd.read_csv("sales_source.csv")
    df["Variance"] = df["Sales"] - df["Target"]
    buf = io.BytesIO()
    with pd.ExcelWriter(buf, engine="openpyxl") as xw:
        df.sort_values("Variance").to_excel(xw, sheet_name="Weekly sales", index=False)
    return buf.getvalue()

@app.retry(strategy="exponential_backoff", max_retry_count="3",
           minimum_interval="00:00:30", maximum_interval="00:05:00")
@app.timer_trigger(schedule="0 0 7 * * 1", arg_name="timer", run_on_startup=False, use_monitor=True)
@app.blob_output(arg_name="report", path="reports/weekly_sales_{DateTime:yyyy-MM-dd}.xlsx",
                 connection="AzureWebJobsStorage")
def weekly_sales(timer: func.TimerRequest, report: func.Out[bytes]) -> None:
    if timer.past_due:
        logging.warning("timer is running late")
    data = build_report()
    report.set(data)
    logging.info("weekly sales report written, %d bytes", len(data))

The workbook is built in a BytesIO buffer and never touches disk: the function's file system is temporary and, on some plans, shared or read-only outside the temp folder, so in-memory output is the reliable choice. report.set(data) hands the bytes to the binding, and the host writes them to the blob path — {DateTime:yyyy-MM-dd} is a binding expression the host fills in with the current UTC date, giving each run its own file. use_monitor=True makes the host record the schedule's status in storage so that a run missed while the app was down is noticed, and timer.past_due is True when the trigger fires later than planned.

Step 2 — Read the schedule correctly

Azure Functions timer schedules are NCRONTAB expressions with six fields — seconds come first. Checking an expression before deploying saves a week of waiting to find out it was wrong:

Python
from datetime import datetime
from croniter import croniter

def next_runs(expr, start, n=3):
    it = croniter(expr, start, second_at_beginning=True)
    return [it.get_next(datetime).strftime("%a %d %b %H:%M:%S") for _ in range(n)]

print(next_runs("0 0 7 * * 1", datetime(2026, 9, 29, 12, 0)))
print(next_runs("0 30 6 * * 1-5", datetime(2026, 9, 29, 12, 0)))

0 0 7 * * 1 is 07:00:00 every Monday; 0 30 6 * * 1-5 is 06:30 on weekdays. A five-field expression copied from a Linux crontab does not mean the same thing in a six-field format — the positions no longer line up with seconds, minutes and hours — so always write all six fields and check them as above rather than relying on how the host interprets a shorter line. The croniter check is cheap insurance: second_at_beginning=True tells it the seconds field comes first, exactly as in Azure. Schedules are evaluated in UTC by default; the WEBSITE_TIME_ZONE app setting changes that on the plans that support it, and without it a 07:00 UTC run is 08:00 in London in summer and 07:00 in winter.

The six NCRONTAB fields A table of the six fields of an Azure Functions timer schedule — second, minute, hour, day of month, month and day of week — with the values of the weekly Monday 07:00 schedule. second minute hour day month weekday 0 0 7 * * 1 The seconds field comes first — a five-field Linux cron line means something else entirely.

Step 3 — Test the handler and its bindings locally

The decorated function is registered on the FunctionApp object, which can be inspected without the Functions host: its trigger and binding definitions are exactly what the host will read, and the handler itself can be called with a real TimerRequest and a stand-in for the output binding:

Python
import io
import json
import importlib
from azure.functions.timer import TimerRequest

module = importlib.import_module("function_app")
fn = module.app.get_functions()[0]
print(fn.get_function_name())
print(json.dumps(json.loads(fn.get_function_json())["bindings"], indent=None))
print([s.get_dict_repr() for s in fn._settings])

class CapturedOutput:
    def set(self, value):
        self.value = value

out = CapturedOutput()
fn.get_user_function()(TimerRequest(past_due=True), out)
print(pd.read_excel(io.BytesIO(out.value))[["Region", "Variance"]].values.tolist())

The printed bindings show the timer with its schedule and monitor flag and the blob output with its dated path; the retry policy appears as a function setting. Calling the handler builds the real workbook, and reading the captured bytes back shows the regions sorted by variance, West first. A test like this in CI catches the mistakes that otherwise only show up after deployment — a wrong binding name, a typo in the path expression, a schedule with five fields. Test Excel Output with pytest shows how to turn it into a proper test.

Step 4 — Run locally and deploy

With Core Tools installed, func start runs the app on your machine against the storage in local.settings.json — usually the Azurite emulator — and triggers timers on schedule; run_on_startup=True temporarily makes it run immediately for testing. Deploy with Core Tools or the Azure CLI:

Bash
func start                                           # local run
func azure functionapp publish acme-reports-func     # deploy the project folder

On Azure, AzureWebJobsStorage is an app setting pointing at the Storage account; the report blobs land in its reports container. Configure the app with a managed identity and identity-based connections rather than connection strings with account keys where your organisation's policies call for it — Upload an Excel Report to Azure Blob Storage covers the storage side.

Limits that decide whether a report fits

Functions are meant for short tasks. On the classic Consumption plan an execution times out after five minutes by default and ten at most; Flex Consumption, Premium and Dedicated plans allow much longer runs. Memory is limited too. A report that queries a database, aggregates a few hundred thousand rows and writes a workbook fits comfortably; one that loads millions of rows into pandas does not, and belongs in a container job instead — see Run a Python Excel Report in Docker. Cold starts add a few seconds to the first run after a quiet period, which does not matter for a weekly job. pandas and openpyxl themselves take a moment to import, so keep heavy optional libraries out of requirements.txt unless the report needs them.

Function or container job? Two panels. A timer function suits short reports that finish in minutes with modest memory. A container job suits long-running reports, large data volumes or heavy dependencies such as LibreOffice. Timer-triggered function minutes, not hours modest data in memory bindings write the output Consumption: 10-minute cap Container job long runs large data volumes heavy dependencies like LibreOffice more to set up

Common pitfalls

  • Five-field cron expressions. Azure timers use six fields with seconds first; write 0 0 7 * * 1, not the Linux-style 0 7 * * 1.
  • Writing the workbook to disk. Build it in memory and hand the bytes to the binding.
  • Forgetting UTC. Schedules and {DateTime} are UTC unless the time zone is configured.
  • Long reports on Consumption. Anything near the ten-minute cap needs a different plan or a container job.
  • Heavy packages. Every dependency in requirements.txt is installed at deployment and loaded at cold start; keep the list short.

Conclusion

Put the report logic in a plain function that returns workbook bytes, and wrap it in a v2 handler with @app.timer_trigger on a six-field NCRONTAB schedule and @app.blob_output writing to a dated path. Add a retry policy, check the schedule with croniter, and test the registered bindings and the handler locally before deploying with Core Tools. Keep reports short enough for the plan's time limit, and move larger ones to a container job.

Frequently asked questions

How do I schedule a Python script in Azure Functions? Create a function app with a timer trigger: in the Python v2 model, decorate a function with @app.timer_trigger(schedule="0 0 7 * * 1", arg_name="timer"). The schedule is an NCRONTAB expression with six fields, starting with seconds.

Why does my Azure timer trigger run at the wrong time? Usually either the expression does not have the six fields Azure expects, with seconds first, or the schedule is being evaluated in UTC while you expected local time. Use six fields with seconds first and set WEBSITE_TIME_ZONE where supported if you need local time.

How do I save an Excel file from an Azure Function? Build the workbook in a BytesIO buffer and pass the bytes to a blob output binding with report.set(data), or upload them with the Azure Storage SDK. Avoid relying on the function's local file system.

How long can an Azure Function run? On the Consumption plan the default timeout is five minutes and the maximum ten. Flex Consumption, Premium and Dedicated plans allow longer executions.

More on scheduling & orchestration

More on scheduling & orchestration →