In Prefect, what do the @flow and @task decorators do to a Python function?
answer
- Two decorators, two kinds of run
- Ordinary functions, orchestration bolted on
- One is the container, one the unit
- Type hints become validated parameters
- 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 sPrefect 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 linesfrom 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
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.
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.
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.
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