skip to content

In Prefect, what do the @flow and @task decorators do to a Python function?

level: juniorimportance: must knowfreq 80%

answer

  1. Two decorators, two kinds of run
  2. Ordinary functions, orchestration bolted on
  3. One is the container, one the unit
  4. Type hints become validated parameters
  5. The graph appears as the code runs

basics

~20 s

@flow turns a function into an orchestrated flow run with tracked state, validated parameters and logging. @task marks a unit of work called inside it, giving each call its own tracked run with retries, caching and concurrency options.

solid answer

~40 s

Prefect does not ask you to declare a graph up front — you decorate ordinary Python functions. `@flow` wraps a function so that every call creates a **flow run**: a record with a state, parameters coerced and validated against the function's type hints, structured logs, and options such as `retries`, `timeout_seconds` and `task_runner`. `@task` wraps a function whose calls become **task runs** inside that flow, each with its own state, `retries`, `timeout_seconds`, tags and caching options. The body is unchanged: you can still unit-test the underlying function through `.fn()`. Crucially the dependency graph is discovered by *executing* the flow — whatever tasks the code calls, in whatever loop or branch, become task runs. That runtime-built graph is the contrast interviewers are fishing for against a statically parsed DAG file.

code

python · 24 lines
python
from prefect import flow, task


@task
def fetch(region: str) -> list[dict]:
    return [{"region": region}]


@task
def load(rows: list[dict]) -> int:
    return len(rows)


@flow(name="nightly-load", log_prints=True)
def nightly(regions: list[str]) -> int:
    total = 0
    for region in regions:
        total += load(fetch(region))
    print(f"loaded {total} rows")
    return total


if __name__ == "__main__":
    nightly(["eu", "us"])

go deeper

for a junior

Be ready to write a five-line script from memory: import flow and task, decorate two functions, call one inside the other, and say what each decorator gives you.

for a middle

Explain the mechanics: what a flow run and a task run actually record, how type hints become validated parameters, and why the run graph exists only after execution.

for a senior

Show judgment about task granularity — tasks are retry, cache and concurrency boundaries, so decorating everything or nothing both hurt operability when a pipeline fails at 3am.

for a principal

Own the tradeoff of runtime-built graphs: expressive and testable, but nothing can be statically inspected or diffed before a run, which shapes review, change safety and how you communicate pipeline shape to a team.

## The starting point: plain Python Prefect's design claim is that a pipeline should be ordinary Python you can read, import and unit-test, with orchestration added by decoration rather than by writing a separate graph description. Both decorators come from the top-level package: ```python from prefect import flow, task ``` ## What `@flow` does Decorating a function with `@flow` returns a `Flow` object that is still callable like the original function. Calling it starts a **flow run** — the unit Prefect tracks and displays. Concretely you get: - **A run record with a state.** The run moves through states and ends Completed, Failed, Crashed or Cancelled, and is visible in the Prefect UI/API with timings. - **Parameter handling.** The arguments become named flow-run parameters, coerced and validated against the function's type hints using Pydantic. Passing `"3"` to a parameter annotated `int` coerces; passing `"abc"` fails the run before any task executes. - **A logger.** `get_run_logger()` inside the function emits logs attached to the run; `@flow(log_prints=True)` also captures bare `print()` calls. - **Flow-level options**: `name`, `retries`, `retry_delay_seconds`, `timeout_seconds`, `task_runner`, `description`, `version`. A flow is also the unit that can later be packaged and scheduled for remote execution — that packaging step is a separate topic; here the point is that a flow run is what Prefect orchestrates. ## What `@task` does `@task` marks a discrete unit of work. Every call inside a flow becomes a **task run** with its own state, its own logs and its own retry/caching configuration: ```python @task(retries=3, retry_delay_seconds=5) def fetch(url: str) -> dict: ... ``` Because the task run is the retry and caching boundary, granularity is a design decision: one task that fetches, transforms and loads can only be retried as a whole, while three tasks can be retried and cached independently and show up separately when something fails. A task is not required for correctness — a flow can call plain helper functions, and they simply run as normal Python with no separate run record. You promote a function to a task when you want it observable, retryable, cacheable or independently schedulable onto the task runner. ## The graph is built by running the code There is no `>>` operator and no graph object to assemble. The structure of the run is whatever the interpreter does: ```python @flow def nightly(regions: list[str]): for region in regions: raw = fetch(region) if raw["rows"]: load(raw) ``` If `regions` has four entries, four `fetch` task runs appear; `load` appears only for the regions that had rows. Branching, loops, comprehensions and early returns are all just Python. The upside is expressiveness — the shape can depend on data fetched during the run. The downside, and the honest thing to say in an interview, is that nothing can be known before execution: there is no static picture of the whole workflow to inspect ahead of time, and a typo in a rarely-taken branch is discovered when that branch runs. ## What the decorators do *not* do Three common misreadings: 1. **They do not add a schedule.** A decorated flow is just a callable; running it on a cadence requires deploying it, which is separate configuration. 2. **They do not parallelise anything by themselves.** Calling a task normally runs it inline, in order, in the flow's own thread. Concurrency requires submitting the task to a task runner. 3. **They do not require a server.** Run the script and it executes; without a configured backend you still get local orchestration behaviour, and with one you get the persisted run records and UI. ## Testability The undecorated function stays reachable as `fetch.fn(...)`, so unit tests can call the pure logic without creating a task run. You can also override configuration for a specific use with `flow.with_options(retries=0)` rather than editing the decorator. ## Version notes The decorators themselves are stable across Prefect 2 and 3, which is why this is a safe screening question. Two details did move: Prefect 3 changed the default task runner to `ThreadPoolTaskRunner`, and Prefect 3 allows a task to be called outside of a flow, which Prefect 2 rejected. Say which major version you are describing when a detail depends on it. ## Interview framing A strong answer names the two run types (flow run, task run), says what each buys you (state, logs, retries, caching, parameter validation), and closes with the runtime-graph point: Prefect's structure is the result of execution, not a declaration parsed beforehand.

  • If a flow can call plain Python helpers, when is it worth making one a task?
    Promote a function to a task when you want it to be a retry, cache, timeout or concurrency boundary, or when you want it visible as its own run in the UI. Pure, fast, deterministic helpers gain nothing but overhead — leave them as functions and keep the task count meaningful.
  • How do you unit-test the logic inside a Prefect task without creating a task run?
    Call the undecorated function through `.fn()`, for example `fetch.fn("eu")`, which runs the raw Python with no orchestration. For a flow you usually do want the run, so call it normally in the test and assert on the returned value, optionally using `flow.with_options(retries=0)` to keep failures fast.
  • What happens if a caller passes a value that does not match a flow parameter's type hint?
    Prefect coerces parameters against the annotations using Pydantic. A coercible value like the string `"3"` for an `int` is converted; a value that cannot be coerced fails the flow run with a validation error before any task executes. `@flow(validate_parameters=False)` disables this if you need raw pass-through.

saying these in an interview costs you the question

  • Claiming @flow adds a schedule by itself
  • Thinking @task makes calls run in parallel automatically
  • Describing a static graph parsed before execution, Airflow-style
  • Assuming a decorated function can no longer be called or tested normally
  • Saying every helper function must be a task

context