Kal
04/06/2026, 4:09 PMMarvin
04/06/2026, 4:09 PMMarvin
04/06/2026, 4:13 PMflow.from_source(...) or with prefect.yaml pull steps (not GitHub storage blocks).
- Manage dependencies by baking them into a Docker image for Docker/Kubernetes pools; for Process pools or lighter needs, install at runtime via pull steps or EXTRA_PIP_PACKAGES.
- If you must run scripts, wrap them in a small flow and call them with prefect-shell for observability, retries, and logs. Even better: refactor scripts into Python tasks/subflows when you can.
Below are two solid, supported setups.
Option A: Programmatic deploy from Git with a flow that runs your script
python
# flows/run_script.py
from prefect import flow
from prefect_shell import shell_run_command # pip install prefect-shell
@flow(log_prints=True)
async def run_script():
# Execute a Python script in your repo
out = await shell_run_command("python scripts/my_job.py")
print(out)
return out
if __name__ == "__main__":
# Deploy from a Git repo (public or private with credentials, see notes below)
flow.from_source(
source="<https://github.com/your-org/your-repo.git>",
entrypoint="flows/run_script.py:run_script",
).deploy(
name="run-script",
work_pool_name="my-work-pool", # process, docker, or k8s
# For small adds on Process/Docker/K8s pools you can do:
# job_variables={"env": {"EXTRA_PIP_PACKAGES": "prefect-shell"}},
# For Docker/K8s in prod, prefer a custom image with all deps preinstalled.
)
Option B: YAML-driven with pull steps to clone + install deps, then run a flow that shells to your script
yaml
# prefect.yaml
pull:
- prefect.deployments.steps.git_clone:
id: repo
repository: <https://github.com/your-org/your-repo.git>
# For private repos:
# credentials: "{{ prefect.blocks.github-credentials.my-creds }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ repo.directory }}"
requirements_file: requirements.txt
- prefect.deployments.steps.set_working_directory:
directory: "{{ repo.directory }}"
deployments:
- name: run-script
entrypoint: flows/run_script.py:run_script
work_pool:
name: my-process-pool
Flow that wraps your script (used by the YAML above)
python
from prefect import flow
from prefect_shell import shell_run_command
@flow
async def run_script():
return await shell_run_command("python scripts/my_job.py")
Notes and best practices
- Code retrieval
- Use flow.from_source("<https://github.com/org/repo.git>", entrypoint="file.py:flow") or the git_clone pull step. GitHub storage blocks are removed in 3.x.
- For private repos, use a credentials block (e.g., `prefect-github`’s GitHubCredentials) or pass a token to git_clone.
- Dependencies
- Production (Docker/Kubernetes): bake dependencies into your image for fast, reliable starts. Runtime pip install is fine for small, infrequent installs or Process pools, but slower and less reliable.
- If you need runtime installs from your repo: use pip_install_requirements pull step pointing at your checked-out requirements.txt.
- Small adds across pools: job_variables.env.EXTRA_PIP_PACKAGES="pandas requests" works, but don’t rely on it for heavy stacks.
- Running scripts
- Use prefect_shell.shell_run_command("...") for single commands, or ShellOperation(commands=[...]).run() for multi/long-running sequences.
- Prefer a Python flow entrypoint rather than doing heavy work in pull steps—flows give you retries, metrics, and lineage.
Helpful docs
- Storing/retrieving flow code: Retrieve code from storage
- CI/CD and deployments: Build deployments via CI/CD
- Docker: Run flows in Docker containersMarvin
04/06/2026, 4:13 PMKal
04/06/2026, 4:32 PM# Prefect logging config file.
#
# Any item in this file can be overridden with an environment variable:
# `PREFECT_LOGGING_[PATH]_[TO]_[KEY]=VALUE`
#
# Templated values can be used to insert values from the Prefect settings at runtime.
version: 1
disable_existing_loggers: False
formatters:
simple:
format: "%(asctime)s.%(msecs)03d %(levelname)-7s | %(message)s | %(filename)s line:%(lineno)d"
datefmt: "%H:%M:%S"
prefect:
class: prefect.logging.formatters.PrefectFormatter
format: "%(asctime)s.%(msecs)03d | %(levelname)-7s | %(name)s - %(message)s"
flow_run_fmt: "%(asctime)s.%(msecs)03d | %(levelname)-7s | Flow run %(flow_run_name)r - %(message)s"
task_run_fmt: "%(asctime)s.%(msecs)03d | %(levelname)-7s | Task run %(task_run_name)r - %(message)s"
datefmt: "%H:%M:%S"
debug:
format: "%(asctime)s.%(msecs)03d | %(levelname)-7s | %(threadName)-12s | %(name)s - %(message)s"
datefmt: "%H:%M:%S"
json:
'()': prefect.logging.formatters.JsonFormatter
format: default
fmt: default
# filters:
# Define any custom filters to drops records containing
# sensitive information
# my_filter:
# class: your_module.FilterClass
handlers:
# The handlers we define here will output all logs they receive by default
# but we include the `level` so it can be overridden by environment
console:
level: INFO
class: logging.StreamHandler
formatter: simple
json:
level: INFO
class: logging.StreamHandler
formatter: json
debug:
level: DEBUG
class: logging.StreamHandler
formatter: debug
loggers:
prefect:
level: "${PREFECT_LOGGING_LEVEL}"
prefect.server:
level: "${PREFECT_LOGGING_SERVER_LEVEL}"
prefect._internal:
level: "${PREFECT_LOGGING_INTERNAL_LEVEL}"
uvicorn:
level: "${PREFECT_LOGGING_SERVER_LEVEL}"
fastapi:
level: "${PREFECT_LOGGING_SERVER_LEVEL}"
# The root logger: any logger without propagation disabled sends to here as well
root:
level: INFO
handlers: [json]
I would ideally like ALL logs formatted to JSON.
Instead I see logs like:
16:27:20.000 | INFO | Task run 'test_run' - PID 200 stderr:
WARNING:__main__:test WARN log
And INFO logs inside the script seem to be ignored completely.Marvin
04/06/2026, 4:32 PMMarvin
04/06/2026, 4:35 PMexport PREFECT_LOGGING_SETTINGS_PATH="/opt/prefect/logging.yml"
prefect worker start --pool my-process-pool --type process
- Docker/Kubernetes work pools:
- Bake or mount the file into the image/pod and set PREFECT_LOGGING_SETTINGS_PATH via the work pool template or deployment job_variables.
- Example deployment snippet:
deployments:
- name: my-deployment
work_pool:
name: my-docker-pool
job_variables:
image: my-registry/my-image:latest
env:
PREFECT_LOGGING_SETTINGS_PATH: "/etc/prefect/logging.yml"
- Ensure /etc/prefect/logging.yml exists in the image/pod at container start.
A minimal JSON config that works well with Prefect
- Prefer Prefect’s handlers so logs still reach the API/UI and the console formatting is consistent:
version: 1
disable_existing_loggers: False
formatters:
json:
'()': prefect.logging.formatters.JsonFormatter
fmt: default
handlers:
console:
level: 0
class: prefect.logging.handlers.PrefectConsoleHandler
formatter: json
stream: <ext://sys.stderr>
api:
level: 0
class: prefect.logging.handlers.APILogHandler
loggers:
prefect:
level: "${PREFECT_LOGGING_LEVEL}"
handlers: [console, api]
root:
level: INFO
handlers: [console]
Once the flow-run process picks this up at startup, every Prefect log record (including messages produced by prefect-shell) will be emitted as JSON.
B) About the “PID … stderr: WARNING:__main__…” and missing INFO from your script
- When you run a script via prefect-shell, you are launching a separate Python process. That process has its own logging, which by default:
- Emits WARNING and above
- Sends logs to stderr
- Uses non-JSON formatting unless the script configures logging itself
- prefect-shell then reads the subprocess output and re-logs those lines through Prefect (so they appear in the UI). If your Prefect logger is JSON-formatted, these lines will be wrapped in JSON — but the line contents themselves are whatever the subprocess printed.
Ways to improve script logs:
- Best: Run the code in-process instead of as a script. Import the function(s) from the repo and call them inside your flow. Then all logs come from the same process and inherit your JSON config.
- If you must keep the script:
- Configure logging in the script to emit INFO and (optionally) JSON:
# at the top of scripts/my_job.py
import logging
logging.basicConfig(level=<http://logging.INFO|logging.INFO>) # so INFO lines actually emit
For JSON inside the script itself you’d need a JSON formatter in that process too (e.g., python-json-logger or your own dictConfig).
- Prefer shell_run_command for cleaner line output (no “PID …” prefix):
```
from prefect import flow
from prefect_shell import shell_run_command
@flow
async def run_script():
# stdout lines streamed at INFO
return await shell_run_command("python -u scripts/my_job.py")Marvin
04/06/2026, 4:35 PMshell_run_command streams stdout; stderr is surfaced if the command fails. If you need both stdout and stderr streamed live, ShellOperation(stream_output=True) does that but prefixes lines (still JSON-formatted once your Prefect config is applied).
- Ensure the script actually emits the log level you want. By default, Python’s logging is WARNING+, so INFO won’t appear unless you set the level in the script.
Quick checklist
- Confirm the JSON config file is present at container/pod/machine startup
- Set PREFECT_LOGGING_SETTINGS_PATH for the worker/pool or place the file in $PREFECT_HOME/logging.yml
- Use PrefectConsoleHandler + APILogHandler in your config
- For scripts, either run code in-process or set logging.basicConfig(level=INFO) inside the script
Docs for reference
- Customizing logging in Prefect 3.x: Logging customization
- prefect-shell usage: prefect-shell integration
If you can share which work pool you’re using (process, docker, kubernetes) and how you’re currently providing the logging.yml to the runtime, I can give you an exact, drop-in configuration.Kal
04/06/2026, 7:19 PMMarvin
04/06/2026, 7:19 PMMarvin
04/06/2026, 7:19 PMfrom prefect import flow
from prefect_shell import shell_run_command
@flow
async def run_script():
# streams stdout lines at INFO (adjust with stream_level)
return await shell_run_command("python -u scripts/my_job.py")
- Multi-command / long-running with ShellOperation
from prefect import flow
from prefect_shell import ShellOperation
@flow
async def run_job():
async with ShellOperation(
commands=[
"pip install -r requirements.txt",
"python -u scripts/my_job.py",
],
working_dir="/app",
stream_output=True, # will prefix lines (PID … stream output / stderr)
) as op:
result = await op.arun()
return result
If your main goal is “all logs as JSON and minimal extra text,” prefer shell_run_command for simple cases. Keep ShellOperation for multi-step or background runs where its orchestration features are helpful.
Docs: prefect-shell integrationKal
04/06/2026, 7:28 PMMarvin
04/06/2026, 7:29 PMMarvin
04/06/2026, 7:29 PMfrom prefect import flow
from prefect_shell import shell_run_command
@flow
def run():
out = shell_run_command("python -u scripts/my_job.py", return_all=True)
return out
- Async flow
from prefect import flow
from prefect_shell import shell_run_command
@flow
async def run():
out = await shell_run_command("python -u scripts/my_job.py", return_all=True)
return out
A couple notes:
- Default stream_level is INFO; adjust if you want different log levels.
- For multi-command or background/long-running jobs, keep using ShellOperation (.run/.arun or .trigger/.atrigger).
Docs: prefect-shell integrationKal
04/06/2026, 7:50 PMTypeError: JsonFormatter.__init__() is missing 2 required positional arguments: 'dmft' and 'style'Marvin
04/06/2026, 7:50 PMMarvin
04/06/2026, 7:51 PMversion: 1
disable_existing_loggers: False
formatters:
json:
'()': prefect.logging.formatters.JsonFormatter
fmt: default # or "pretty"
dmft: null # unused but required by the constructor
style: '%' # unused but required by the constructor
handlers:
console:
level: 0
class: prefect.logging.handlers.PrefectConsoleHandler
formatter: json
stream: <ext://sys.stderr>
api:
level: 0
class: prefect.logging.handlers.APILogHandler
loggers:
prefect:
level: "${PREFECT_LOGGING_LEVEL}"
handlers: [console, api]
propagate: False
root:
level: INFO
handlers: [console]
Notes
- If you had both format and fmt under the formatter, drop format; only fmt is valid here.
- Make sure this file is loaded at flow-run process startup (e.g., set PREFECT_LOGGING_SETTINGS_PATH to the absolute path on the worker/pod/container).
Once applied, all Prefect-emitted logs (including those re-logged from prefect-shell) will be JSON. The raw content coming from your subprocess remains whatever the script prints, but it’ll be wrapped as a JSON log record by Prefect.Kal
04/06/2026, 8:50 PMMarvin
04/06/2026, 8:50 PMMarvin
04/06/2026, 8:50 PMawait shell_run_command("python -u scripts/my_job.py 2>&1")
- With an explicit shell:
await shell_run_command(
"python -u scripts/my_job.py 2>&1",
shell="bash"
)
- PowerShell:
await shell_run_command("python -u scripts/my_job.py 2>&1", shell="powershell")
- Make the script log to stdout instead of stderr:
# inside scripts/my_job.py
import logging, sys
logging.basicConfig(level=<http://logging.INFO|logging.INFO>, stream=sys.stdout)
Then INFO/WARNING/etc. will show up via stdout and be streamed by shell_run_command.
- If you truly need both streams without shell redirection, stick with ShellOperation(stream_output=True).
Notes
- shell_run_command streams lines at INFO by default (adjust with stream_level) and returns only the last line unless you set return_all=True.
- ShellOperation prefixes lines (PID … stream output/stderr) but that’s why you were seeing the warnings. It’s the better fit when you need long-running/background control or both streams without shell tricks.
Docs: prefect-shell integrationKal
04/06/2026, 8:57 PM20:46:00.000 | INFO | Task run 'testing_laoder' - PID 309: stderr:
20:46:00.000 | INFO | Flow run 'ambitious-muskrat' - Beginning sublow run 'ambitious-muskrat' for flow 'Testing Flow'Marvin
04/06/2026, 8:57 PMMarvin
04/06/2026, 8:58 PMprefect logger and disables propagation.
- If your dictConfig does not explicitly override the prefect (and friends) loggers, those default handlers will continue to emit non‑JSON lines.
- Fix: redefine the prefect, prefect.flow_runs, and prefect.task_runs loggers in your config and set your JSON console handler on them with propagate: False.
2) You might be looking in two different places
- Console/stdout of the worker/container: controlled by your console handler/formatter.
- Prefect UI/API logs: sent by APILogHandler and shown as message text in the UI, not as a JSON blob. Even with a JSON formatter on console, UI will still render the message string (not a JSON record).
A “force JSON everywhere on console” config
- This replaces Prefect’s default console formatting for flow/task logs and ensures nothing else leaks through.
version: 1
disable_existing_loggers: False
formatters:
json:
'()': prefect.logging.formatters.JsonFormatter
fmt: default # or "pretty"
dmft: null # required by constructor; unused
style: '%' # required by constructor; unused
handlers:
console:
class: prefect.logging.handlers.PrefectConsoleHandler
level: 0
formatter: json
stream: <ext://sys.stderr>
api:
class: prefect.logging.handlers.APILogHandler
level: 0
loggers:
# Override Prefect's default logger so it uses JSON on console
prefect:
level: "${PREFECT_LOGGING_LEVEL}"
handlers: [console, api]
propagate: False
# Make sure flow/task run child loggers also use JSON, not inherited defaults
prefect.flow_runs:
level: NOTSET
handlers: [console, api]
propagate: False
prefect.task_runs:
level: NOTSET
handlers: [console, api]
propagate: False
# Optional: align uvicorn/fastapi if you run server bits in the same process
uvicorn:
level: INFO
handlers: [console]
propagate: False
fastapi:
level: INFO
handlers: [console]
propagate: False
root:
level: INFO
handlers: [console]
Key points
- The override of prefect, prefect.flow_runs, and prefect.task_runs with propagate: False is the critical bit; it replaces Prefect’s default console handler and stops double emission.
- Ensure this file is loaded at process start (PREFECT_LOGGING_SETTINGS_PATH on the worker/pod/container), not just in your repo.
- Expect the Prefect UI to still show message text, not a JSON object — that’s by design of the API handler.
Can you confirm:
- Where you’re seeing the non‑JSON flow/task lines (container stdout, sidecar/collector logs, or the Prefect UI)?
- Which work pool you’re running (process, docker, k8s) and how you’re mounting/setting PREFECT_LOGGING_SETTINGS_PATH there?
I can tailor the exact env/pool setup once I know those.Kal
04/10/2026, 4:07 PM# Prefect logging config file.
#
# Any item in this file can be overridden with an environment variable:
# `PREFECT_LOGGING_[PATH]_[TO]_[KEY]=VALUE`
#
# Templated values can be used to insert values from the Prefect settings at runtime.
version: 1
disable_existing_loggers: False
formatters:
simple:
format: "%(asctime)s.%(msecs)03d %(levelname)-7s | %(message)s | %(filename)s line:%(lineno)d"
datefmt: "%H:%M:%S"
prefect:
class: prefect.logging.formatters.PrefectFormatter
format: "%(asctime)s.%(msecs)03d | %(levelname)-7s | %(name)s - %(message)s"
flow_run_fmt: "%(asctime)s.%(msecs)03d | %(levelname)-7s | Flow run %(flow_run_name)r - %(message)s"
task_run_fmt: "%(asctime)s.%(msecs)03d | %(levelname)-7s | Task run %(task_run_name)r - %(message)s"
datefmt: "%H:%M:%S"
debug:
format: "%(asctime)s.%(msecs)03d | %(levelname)-7s | %(threadName)-12s | %(name)s - %(message)s"
datefmt: "%H:%M:%S"
json:
'()': prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
style: '%'
# filters:
# Define any custom filters to drops records containing
# sensitive information
# my_filter:
# class: your_module.FilterClass
handlers:
# The handlers we define here will output all logs they receive by default
# but we include the `level` so it can be overridden by environment
console:
level: INFO
class: logging.StreamHandler
formatter: simple
json:
level: 0
class: prefect.logging.handlers.PrefectConsoleHandler
formatter: json
stream: <ext://sys.stdout>
debug:
level: DEBUG
class: logging.StreamHandler
formatter: debug
loggers:
prefect:
level: INFO
handlers: [json]
prefect.flow_runs:
level: NOTSET
handlers: [json]
propagate: False
prefect.task_runs:
level: NOTSET
handlers: [json]
propagate: False
prefect.extra:
level: NOTSET
handlers: [json]
propagate: false
prefect.server:
level: NOTSET
handlers: [json]
propagate: False
prefect.client:
level: NOTSET
handlers: [json]
propagate: False
prefect.infrastructure:
level: NOTSET
handlers: [json]
propagate: False
uvicorn:
level: INFO
handlers: [json]
propagate: False
fastapi:
level: INFO
handlers: [json]
propagate: False
httpx:
level: WARNING
handlers: [json]
# The root logger: any logger without propagation disabled sends to here as well
root:
level: INFO
handlers: [json]Marvin
04/10/2026, 4:07 PMMarvin
04/10/2026, 4:11 PMshell_run_command only streams stdout to the task logger, while ShellOperation streams the process output (stdout and, in practice, often stderr when configured) with its own “PID … stream output” messages. If your script writes logs to stderr (which is common for Python’s default logging.StreamHandler), shell_run_command won’t show them unless you merge stderr into stdout.
What to try
- Redirect stderr to stdout in your command:
await shell_run_command("python your_script.py 2>&1", shell="bash")
or if not using bash, update the command appropriately for your shell.
- Or, change your script to log to stdout instead of stderr:
import logging, sys
handler = logging.StreamHandler(sys.stdout) # stdout instead of default stderr
formatter = logging.Formatter("%(asctime)s.%(msecs)03d %(levelname)-7s | %(message)s")
handler.setFormatter(formatter)
root = logging.getLogger()
root.setLevel(<http://logging.INFO|logging.INFO>)
root.handlers = [handler]
- If you want these logs in the Prefect UI and you’re using a custom logging.yml, be sure you include the API log handler. Your posted config only has a console handler (json). Add:
handlers:
api:
level: INFO
class: prefect.logging.handlers.APILogHandler
loggers:
prefect.task_runs:
level: NOTSET
handlers: [json, api]
propagate: False
prefect.flow_runs:
level: NOTSET
handlers: [json, api]
propagate: False
root:
level: INFO
handlers: [json, api]
And ensure PREFECT_LOGGING_TO_API_ENABLED=true in the environment.
Quick checklist
- Are you running shell_run_command inside a `@flow`/`@task` and awaiting it? Example:
from prefect import flow
from prefect_shell import shell_run_command
@flow
async def demo():
await shell_run_command("python your_script.py 2>&1", shell="bash")
- Verify the command actually prints to stdout (or redirect 2>&1 as above).
- If you expect logs in the UI, include APILogHandler or avoid overriding it with a custom config.
If you can share:
- Where you’re expecting to see the logs (Prefect UI vs worker stdout)
- Your prefect and prefect-shell versions
- How you’re invoking shell_run_command (a small snippet)
…I can tailor the fix precisely.Kal
04/10/2026, 5:18 PM@task(
name="Script",
task_run_name="{script}",
retries=2,
retry_delay_seconds=[10, 30],
timeout_seconds=14400,
)
def extract_and_load_task(script):
<http://logger.info|logger.info>(f"Running script - {script}")
result = shell_run_command(f"poetry run python -m scripts.{script}")
return result
Code for setting up the logger inside the script:
import logging
import logging.config
import yaml
with open(".prefect/logging.yml", "rt") as f:
config = yaml.safe_load(f.read())
# Configure the logging module with the config file
logging.config.dictConfig(config)
logger = logging.getLogger(__name__)Marvin
04/10/2026, 5:18 PMMarvin
04/10/2026, 5:19 PMshell_run_command is async. In your sync @task it’ll return a coroutine-like object and won’t actually run/stream unless awaited.
- Make your task async and await the call. That’s the most common reason for “no streaming logs” with shell_run_command.
2) Only stdout is streamed by shell_run_command
- If your script logs to stderr (default for Python’s logging.StreamHandler), shell_run_command won’t show it unless you redirect stderr to stdout or have your script log to stdout.
Recommended changes
- Make the task async and await:
from prefect import task
from prefect.logging import get_run_logger
from prefect_shell import shell_run_command
import logging
@task(
name="Script",
task_run_name="{script}",
retries=2,
retry_delay_seconds=10, # single value; remove the list
timeout_seconds=14400,
)
async def extract_and_load_task(script: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Running script - {script}")
# Redirect stderr to stdout so both appear in the parent logs
cmd = f"poetry run python -m scripts.{script} 2>&1"
# Use a shell that understands redirection
result = await shell_run_command(
cmd,
shell="bash",
stream_level=<http://logging.INFO|logging.INFO>, # explicit
)
return result
- If you must keep the task sync, you still need to run the async function — easiest is to convert the task to async. Mixing sync task + async shell often leads to exactly this symptom (no stream).
- Ensure your script logs to stdout (optional alternative to 2>&1):
import logging, sys, logging.config, yaml
with open(".prefect/logging.yml", "rt") as f:
config = yaml.safe_load(f.read())
logging.config.dictConfig(config)
# Force stdout for your script’s root logger if needed
for h in logging.getLogger().handlers:
if isinstance(h, logging.StreamHandler):
h.stream = sys.stdout
Sanity test
- Try a minimal flow to confirm you see console output immediately:
from prefect import flow
from prefect_shell import shell_run_command
import logging
@flow
async def test():
await shell_run_command("echo hello 2>&1", shell="bash", stream_level=<http://logging.INFO|logging.INFO>)
if __name__ == "__main__":
import anyio
anyio.run(test)
If you still don’t see the lines:
- Double-check the process running your flow isn’t filtering INFO-level logs to stdout.
- Temporarily bump the level:
await shell_run_command("echo hello", shell="bash", stream_level=logging.DEBUG)
and set your console handler level to DEBUG to verify.Kal
04/10/2026, 5:26 PMMarvin
04/10/2026, 5:26 PMMarvin
04/10/2026, 5:28 PMfrom prefect import flow
from prefect_shell import shell_run_command
@flow
def f():
return shell_run_command("echo hello")
- Async flow:
from prefect import flow
from prefect_shell import shell_run_command
@flow
async def f():
return await shell_run_command("echo hello")
Notes for your current pattern (task calling shell_run_command):
- That inner call runs inline inside the outer task. Logs from shell_run_command will be emitted to the outer task’s logger.
- For your stdout/stderr requirement, remember shell_run_command streams stdout only. If your script’s logging writes to stderr, redirect it:
shell_run_command("poetry run python -m scripts.<name> 2>&1", shell="bash")
- If you want shell_run_command to have its own distinct task run (and its own logging lane), call it directly from the flow instead of from within another task.Kal
04/10/2026, 6:31 PMdef data_loader_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, data_source_name: str
):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {datetime.now(ZoneInfo("America/New_York"))}"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject="Data loader failure",
)
print(f"Sent Slack message for data loader '{data_source_name}' failure")
except Exception as e:
print(f"Error sending Slack notification: {e}")
if isinstance(e, NotificationError):
print(f"Apprise NotificationError: {e.log}")
@task(
name="Data loader",
task_run_name="{data_source_name}",
retries=2,
retry_delay_seconds=[10, 30],
timeout_seconds=14400,
)
def extract_and_load_task(data_source_name):
logger = logging.get_run_logger()
<http://logger.info|logger.info>(f"Running data loader - {data_source_name}")
result = ShellOperation(
commands=[f"poetry run python -m data_loaders.{data_source_name}"]
).run()
return result
@flow(
name="Split Data Source Chunk",
task_runner=ConcurrentTaskRunner(),
flow_run_name="{data_sources_chunk}",
)
def chunk_subflow(data_sources_chunk: list[str], env: str):
results = []
for x in data_sources_chunk:
if x != "":
try:
extract_and_load_task_with_failure_hook = (
extract_and_load_task.with_options(
name=f"'{x}' data loader",
on_failure=[
partial(
data_loader_failure_notify_slack,
**dict(env=env, data_source_name=x),
)
],
)
)
# to use concurrency, call as .._hook.submit(x) and handle futures
result = extract_and_load_task_with_failure_hook(x)
results.append(result)
except Exception as e:
print(f"Data loader '{x}' failed: {e}, continuing run")
return results
This error hook seems to trigger for some errors like ModuleNotFound but if I raise an exception from inside the script called from run_shell_command, the Exception is caught in chunk_subflow without triggering the hook.Marvin
04/10/2026, 6:31 PMMarvin
04/10/2026, 6:34 PMfrom functools import partial
from prefect import flow
from prefect_shell import ShellOperation
@flow(
name="Split Data Source Chunk",
task_runner=ConcurrentTaskRunner(),
flow_run_name="{data_sources_chunk}",
)
def chunk_subflow(data_sources_chunk: list[str], env: str):
futures = []
for x in data_sources_chunk:
if not x:
continue
t = extract_and_load_task.with_options(
name=f"'{x}' data loader",
on_failure=[partial(data_loader_failure_notify_slack, env=env, data_source_name=x)],
)
futures.append((x, t.submit(x))) # submit => Prefect manages state + hooks
results = []
for x, fut in futures:
try:
results.append(fut.result()) # raises on final failure; hook already ran
except Exception as e:
print(f"Data loader '{x}' failed: {e}, continuing run")
return results
Other workable options
- Don’t catch the task exception in the flow. Let it bubble so Prefect can set Failed and run the hook. If you need to continue, use the submit pattern above.
- If you must keep the try/except around the direct task call, re-raise after your log so Prefect can finalize the task state and run the hook.
- Or, force a Failed state inside the task when catching shell errors:
from prefect.states import Failed
@task(on_failure=[...])
def extract_and_load_task(data_source_name):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Running data loader - {data_source_name}")
try:
return ShellOperation(commands=[f"poetry run python -m data_loaders.{data_source_name}"]).run()
except Exception as e:
# Emit a terminal Failed state so the hook runs
return Failed(message=str(e))
A couple of small cleanups in your snippet
- Use Prefect’s logger correctly:
from prefect.logging import get_run_logger
logger = get_run_logger()
<http://logger.info|logger.info>(f"Running data loader - {data_source_name}")
- retry_delay_seconds should be a single number (not a list). If you want backoff, configure retries and a constant delay, or implement backoff inside the task.
Sanity checks
- Make sure your script exits non-zero when it “fails” (don’t swallow exceptions and exit 0).
- If you keep the direct call + try/except, you may be catching Prefect’s internal retry/exception signal; prefer .submit() so Prefect can move the task to a terminal Failed state and run hooks before you handle the error.Kal
04/10/2026, 6:46 PMdef data_loader_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, data_source_name: str
):
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {datetime.now(ZoneInfo("America/New_York"))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(
f"Sent Slack message for data loader '{data_source_name}' failure"
)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
if isinstance(e, NotificationError):
logger.error(f"Apprise NotificationError: {e.log}")
@flow(
name="Data loader",
flow_run_name="{data_source_name}",
retries=2,
retry_delay_seconds=15,
timeout_seconds=14400,
)
def extract_and_load_task(data_source_name):
<http://logger.info|logger.info>(f"Running data loader - {data_source_name}")
result = shell_run_command(f"poetry run python -m data_loaders.{data_source_name}")
return result
@flow(
name="Split Data Source Chunk",
flow_run_name="{data_sources_chunk}",
)
def chunk_subflow(data_sources_chunk: list[str], env: str):
results = []
for x in data_sources_chunk:
if x != "":
try:
extract_and_load_task_with_failure_hook = (
extract_and_load_task.with_options(
name=f"'{x}' data loader",
on_failure=[
partial(
data_loader_failure_notify_slack,
**dict(env=env, data_source_name=x),
)
],
)
)
# to use concurrency, call as .._hook.submit(x) and handle futures
result = extract_and_load_task_with_failure_hook(x)
results.append(result)
except Exception as e:
logger.error(
f"Data loader '{x}' failed: {e}, continuing run", exc_info=True
)
return results
Why would try/except in chunk_subflow cause the hook to fail if the hook is attached to extract_and_load_task? I would expect the hook to be trigger and then the except to be triggered, which is what happens sometimes depending on the type of exception thrown from the inner script.
Also, I do not wish to run any scripts concurrently nor send logs to Prefect.Marvin
04/10/2026, 6:46 PMMarvin
04/10/2026, 6:47 PMfrom functools import partial
from prefect import flow
from prefect.logging import get_run_logger
from prefect_shell import shell_run_command
def data_loader_failure_notify_slack(task, task_run, state, env, data_source_name):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
# ... your Slack code here ...
@flow(name="Data loader", flow_run_name="{data_source_name}")
def extract_and_load_task(data_source_name: str, env: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Running data loader - {data_source_name}")
shell_with_hook = shell_run_command.with_options(
name=f"load-{data_source_name}",
on_failure=[partial(data_loader_failure_notify_slack, env=env, data_source_name=data_source_name)],
retries=2, # move retries to the task, if desired
retry_delay_seconds=15,
)
# sequential execution; no concurrency
return shell_with_hook(f"poetry run python -m data_loaders.{data_source_name}")
Then in your parent flow:
@flow(name="Split Data Source Chunk", flow_run_name="{data_sources_chunk}")
def chunk_subflow(data_sources_chunk: list[str], env: str):
results = []
for x in data_sources_chunk:
if not x:
continue
try:
results.append(extract_and_load_task(x, env))
except Exception as e:
get_run_logger().error(f"Data loader '{x}' failed: {e}, continuing run", exc_info=True)
return results
- Since the hook is on the task, it will run on the task’s terminal failure independently of the subflow’s retry/exception behavior.
Option B: Keep the hook on the flow, but remove flow retries
- If you want a single Slack alert when the whole subflow ultimately fails, remove retries from extract_and_load_task so it goes straight to Failed and triggers the hook. If you still want retries, put them on the task instead (Option A), or accept that the hook only fires on the last attempt.
Notes and small fixes
- Make sure you’re using Prefect’s logger inside flows and hooks:
from prefect.logging import get_run_logger
logger = get_run_logger()
I see placeholders like <http://logger.info|logger.info> in your paste — just confirming you’ve got a real logger reference at runtime.Marvin
04/10/2026, 6:47 PMKal
04/10/2026, 7:07 PM"'coroutine' object has no attribute 'raise_on_failure'"
Seems like the Slack webhook block is now async or something?Marvin
04/10/2026, 7:07 PMMarvin
04/10/2026, 7:09 PMfrom prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.logging import get_run_logger
from zoneinfo import ZoneInfo
from datetime import datetime
from prefect.runtime import flow_run # if you use get_flow_run_ui_url()
def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
# IMPORTANT: use .load (sync), not .aload
webhook = SlackWebhook.load("slack-integration" if env == "prod" else "slack-staging")
ts = datetime.now(ZoneInfo('America/New_York'))
try:
with webhook.raise_on_failure():
webhook.notify(
body=(
f"Data loader `{data_source_name}` failed at {ts}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
logger.error(f"Slack notification failed: {e.log}", exc_info=True)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
- If your hook is async (async def), then either:
- Keep using the sync loader: webhook = SlackWebhook.load("...") and call webhook.notify(...) — Prefect’s async-dispatch will handle it, or
- Use the async loader correctly: webhook = await SlackWebhook.aload("...")
- Confirm the import
- Prefer: from prefect.blocks.notifications import SlackWebhook
- If you’re using from prefect_slack import SlackWebhook, ensure you’re still calling .load(...) (sync) or await .aload(...) (async). Using .aload(...) without await produces exactly the “‘coroutine’ object has no attribute ‘raise_on_failure’” error.
Quick sanity check
- Add a temporary print to confirm type:
print(type(webhook))
You should see <class 'prefect.blocks.notifications.SlackWebhook'>, not <class 'coroutine'>.
Minor note
- In your f-string, use single quotes inside ZoneInfo to avoid a syntax error:
f"... {datetime.now(ZoneInfo('America/New_York'))} ..."
If you share your exact SlackWebhook import line and whether your hook is sync or async, I can tailor the final snippet precisely.Kal
04/10/2026, 7:12 PMfrom prefect.blocks.notifications import SlackWebhook
def data_loader_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, data_source_name: str
):
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {datetime.now(ZoneInfo("America/New_York"))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(
f"Sent Slack message for data loader '{data_source_name}' failure"
)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
if isinstance(e, NotificationError):
logger.error(f"Apprise NotificationError: {e.log}")Marvin
04/10/2026, 7:12 PMMarvin
04/10/2026, 7:13 PMfrom datetime import datetime
from zoneinfo import ZoneInfo
from functools import partial
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.logging import get_run_logger
from prefect.runtime import flow_run
def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
# IMPORTANT: use .load (sync), not .aload
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging"
)
ts = datetime.now(ZoneInfo('America/New_York'))
ui_link = flow_run.ui_url # attribute in 3.x; may be None if no UI configured
try:
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {ts}\n"
f"Flow run URL: {ui_link or 'N/A'}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
logger.error(f"Slack NotificationError: {e.log}", exc_info=True)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
If you do want to run the hook in an async context instead:
- Either keep using .load (it’s fine) and call .notify (async-dispatch handles it), or
- Use the async loader and await it:
slack_webhook_block = await SlackWebhook.aload("slack-integration" if env == "prod" else "slack-staging")
Sanity check
- Temporarily print the type to ensure you’re not dealing with a coroutine:
print(type(slack_webhook_block))
Expect: <class 'prefect.blocks.notifications.SlackWebhook'>
This should resolve the coroutine/attribute error and give you a stable Slack failure hook without touching Prefect UI logging or concurrency.Kal
04/10/2026, 7:51 PMMarvin
04/10/2026, 7:51 PMMarvin
04/10/2026, 7:54 PMfrom datetime import datetime
from zoneinfo import ZoneInfo
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.logging import get_run_logger
from prefect.runtime import flow_run
def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
# Force sync even in async context
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging",
_sync=True,
)
ts = datetime.now(ZoneInfo('America/New_York'))
ui_link = flow_run.ui_url # Prefect 3.x attribute
try:
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {ts}\n"
f"Flow run URL: {ui_link or 'N/A'}"
),
subject=f"Data loader failure - {data_source_name}",
_sync=True, # also force sync here
)
<http://logger.info|logger.info>(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
logger.error(f"Slack NotificationError: {e.log}", exc_info=True)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
Option B: Make the hook async and await
- If you prefer async, change the hook to async def and await the block methods:
from datetime import datetime
from zoneinfo import ZoneInfo
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.logging import get_run_logger
from prefect.runtime import flow_run
async def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = await SlackWebhook.aload(
"slack-integration" if env == "prod" else "slack-staging"
)
ts = datetime.now(ZoneInfo('America/New_York'))
ui_link = flow_run.ui_url
try:
with slack_webhook_block.raise_on_failure():
await slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {ts}\n"
f"Flow run URL: {ui_link or 'N/A'}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
logger.error(f"Slack NotificationError: {e.log}", exc_info=True)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
Also fix the timezone string in your f-string:
- Use single quotes inside ZoneInfo: ZoneInfo('America/New_York')
Why this happens
- Prefect’s async-dispatch picks the async implementation when a hook runs inside the async engine. Without awaiting (or forcing sync via _sync=True), you’ll get a coroutine object, which explains the "'coroutine' object has no attribute 'raise_on_failure'" error.Kal
04/10/2026, 8:10 PM/user/local/lib/python3.12/site-packages/prefect/blocks/notifications.py:129: RuntimeWarning: coroutine 'AbstractAppriseNotificationBlock.anotify' was never awaitedMarvin
04/10/2026, 8:10 PMMarvin
04/10/2026, 8:12 PMfrom datetime import datetime
from zoneinfo import ZoneInfo
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.logging import get_run_logger
from prefect.runtime import flow_run
async def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
# Use the async loader
slack_webhook_block: SlackWebhook = await SlackWebhook.aload(
"slack-integration" if env == "prod" else "slack-staging"
)
ts = datetime.now(ZoneInfo('America/New_York'))
ui_link = flow_run.ui_url # Prefect 3.x attribute
try:
with slack_webhook_block.raise_on_failure():
# Await the async-dispatched notify
await slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {ts}\n"
f"Flow run URL: {ui_link or 'N/A'}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
logger.error(f"Slack NotificationError: {e.log}", exc_info=True)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
Notes
- Hooks can be async; the engine will await them.
- Keep using prefect.blocks.notifications.SlackWebhook; just switch to aload + await notify.
- Make sure the ZoneInfo string uses single quotes inside the f-string: ZoneInfo('America/New_York').
- If you prefer to keep a sync hook, the only truly reliable workaround is to avoid notify’s dispatch entirely (e.g., use prefect_slack’s SlackWebhook and call its sync client), but the async hook above is the cleanest path in Prefect 3.x.
If you still want a strictly sync hook, say so and I’ll share a safe sync-only alternative.Kal
04/10/2026, 8:14 PMMarvin
04/10/2026, 8:14 PMMarvin
04/10/2026, 8:14 PMfrom datetime import datetime
from zoneinfo import ZoneInfo
from functools import partial
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.logging import get_run_logger
from prefect.runtime import flow_run
async def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for data loader '{data_source_name}' failure")
# Async load in async hook
slack_webhook_block: SlackWebhook = await SlackWebhook.aload(
"slack-integration" if env == "prod" else "slack-staging"
)
ts = datetime.now(ZoneInfo('America/New_York'))
ui_link = flow_run.ui_url # attribute in Prefect 3.x
try:
with slack_webhook_block.raise_on_failure():
# Await notify in async context
await slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {ts}\n"
f"Flow run URL: {ui_link or 'N/A'}"
),
subject=f"Data loader failure - {data_source_name}",
)
<http://logger.info|logger.info>(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
logger.error(f"Slack NotificationError: {e.log}", exc_info=True)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
- Keep your existing flow/task code and with_options(on_failure=[partial(...)]); no other changes required.
- The engine will detect the hook is async and await it, ensuring the Slack send actually happens and preventing the “coroutine was never awaited” warning.Kal
04/16/2026, 12:05 AMMarvin
04/16/2026, 12:05 AMMarvin
04/16/2026, 12:07 AMshell_run_command uses anyio’s TextReceiveStream to read from the subprocess. That stream yields variable-length decoded chunks (which can include multiple lines) and Prefect logs each chunk as a single record.
- Reference: prefect-shell implementation
- https://github.com/PrefectHQ/prefect-shell/blob/main/prefect_shell/commands.py
Ways to get one JSON log record per line
1) Keep shell_run_command, but re-log lines individually
- Use return_all=True to get a list of lines and then emit one Prefect log per line:
from prefect import flow
from prefect.logging import get_run_logger
from prefect_shell import shell_run_command
@flow
async def run():
logger = get_run_logger()
lines = await shell_run_command(
command="bash -lc 'echo one; echo two; echo three'",
return_all=True,
)
for line in lines:
<http://logger.info|logger.info>(line) # one JSON record per line
Note: The stream during execution will still be chunked, but your final logs will be per line.
2) Use ShellOperation and control streaming
- ShellOperation lets you disable live streaming, then you can log each line yourself:
from prefect import flow
from prefect.logging import get_run_logger
from prefect_shell import ShellOperation
@flow
def run():
logger = get_run_logger()
op = ShellOperation(
commands=["echo one", "echo two", "echo three"],
stream_output=False, # don't stream clumped logs
)
result_lines = op.run() # returns list[str]
for line in result_lines:
<http://logger.info|logger.info>(line) # one JSON record per line
3) If you must see live, line-by-line streaming
- Prefer `ShellOperation`’s synchronous execution path (which reads with .readline() and is inherently line-based), or implement a custom task using subprocess.Popen(..., text=True) and iterate for line in proc.stdout.
- In practice, true line-by-line streaming with the async shell_run_command isn’t guaranteed because of the chunked nature of the underlying stream.
Small extra tips
- Adding stdbuf -oL -eL to your command can encourage line-buffered output from the child process, which can reduce clumping, but it won’t fully prevent multiple lines from arriving in a single chunk:
- Example: command="bash -lc 'stdbuf -oL -eL your_command_here'"
A couple of quick questions so I can tailor this precisely:
- Are you using prefect_shell.shell_run_command or ShellOperation?
- Which versions of Prefect and prefect-shell are you on?
Links
- prefect-shell code: https://github.com/PrefectHQ/prefect-shell
- commands.py (stdout/stderr streaming implementation): https://github.com/PrefectHQ/prefect-shell/blob/main/prefect_shell/commands.pyKal
04/16/2026, 12:20 AMMarvin
04/16/2026, 12:20 AMMarvin
04/16/2026, 12:22 AMshell_run_command streams stdout via anyio’s TextReceiveStream in variable-sized chunks. Prefect logs each chunk as a single record, so multiple lines often “clump” into one JSON log entry separated by “\n”. There isn’t a flag in shell_run_command to force line-by-line streaming in that version.
What you can do instead
- If you just want one Prefect log record per line (not necessarily live):
- Capture all lines and re-log them line-by-line yourself.
from prefect import flow
from prefect.logging import get_run_logger
from prefect_shell import shell_run_command
@flow
async def run():
logger = get_run_logger()
lines = await shell_run_command(
command="bash -lc 'echo one; echo two; echo three'",
return_all=True, # ensures you get a list[str]
)
for line in lines:
<http://logger.info|logger.info>(line) # one JSON log entry per line
- If you want to avoid clumped streaming and still get per-line logs:
- Use ShellOperation with stream_output=False to suppress the chunked streaming, then log the resulting lines yourself.
from prefect import flow
from prefect.logging import get_run_logger
from prefect_shell import ShellOperation
@flow
def run():
logger = get_run_logger()
op = ShellOperation(
commands=["echo one", "echo two", "echo three"],
stream_output=False, # don't emit chunked stream logs
)
result_lines = op.run() # returns list[str]
for line in result_lines:
<http://logger.info|logger.info>(line) # one JSON log entry per line
- If you need true live, line-by-line streaming:
- Implement a small custom task that reads a subprocess line-by-line (e.g., with subprocess.Popen(..., text=True)), and logs each line as it’s produced.
from prefect import flow, task
from prefect.logging import get_run_logger
import subprocess
@task
def run_shell_line_stream(cmd: str):
logger = get_run_logger()
with subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) as p:
for line in p.stdout: # line-by-line
<http://logger.info|logger.info>(line.rstrip())
return p.wait()
@flow
def run():
run_shell_line_stream("bash -lc 'echo one; echo two; echo three'")
Tips to reduce clumping at the source
- Force the child process to line-buffer output:
- Wrap your command: bash -lc 'stdbuf -oL -eL your_command_here'
- For Python scripts: set PYTHONUNBUFFERED=1 in env or -u on the command
References
- prefect-shell source (streaming implementation lives here): https://github.com/PrefectHQ/prefect-shell/blob/main/prefect_shell/commands.py
Quick check: do you need real-time per-line streaming, or is it sufficient to produce one log record per line after the command finishes? I can tailor the approach based on that.Kal
04/16/2026, 12:30 AMMarvin
04/16/2026, 12:30 AMMarvin
04/16/2026, 12:31 AMfrom prefect import flow
from prefect_shell import ShellOperation
@flow
def run():
op = ShellOperation(
commands=["bash -lc 'echo one; sleep 1; echo two; sleep 1; echo three'"],
stream_output=True, # live streaming
)
op.run() # logs each line as it's emitted
Notes:
- This runs synchronously inside your flow. If you need high async/concurrent throughput, see Option B.
Option B: Custom async runner that logs per-line in real time
- shell_run_command streams in chunks; to get one log record per line during execution, read the async stream, split on newlines, and emit one log call per line.
from prefect import flow
from prefect.logging import get_run_logger
import anyio
import logging
from anyio.streams.text import TextReceiveStream
async def stream_lines(cmd: str, env: dict | None = None, cwd: str | None = None) -> int:
logger = get_run_logger()
# Adjust shell as needed (Windows vs Linux)
shell = ["/bin/bash", "-lc", cmd]
async with await anyio.open_process(shell, env=env, cwd=cwd) as proc:
async def pump(byte_stream, level: int):
recv = TextReceiveStream(byte_stream)
buf = ""
async for chunk in recv:
buf += chunk
while "\n" in buf:
line, buf = buf.split("\n", 1)
if line:
logger.log(level, line)
if buf:
logger.log(level, buf)
async with anyio.create_task_group() as tg:
tg.start_soon(pump, proc.stdout, <http://logging.INFO|logging.INFO>)
tg.start_soon(pump, proc.stderr, logging.WARNING)
return await proc.wait()
@flow
async def run():
rc = await stream_lines("echo one; sleep 1; echo two; sleep 1; echo three")
return rc
Tips to ensure timely line emission from the child process
- Force line-buffering/unbuffered output so each newline flushes immediately:
- Wrap your command: bash -lc 'stdbuf -oL -eL your_command'
- For Python: set PYTHONUNBUFFERED=1 or use python -u script.py
- This reduces latency for each line and helps avoid the source process batching its output.
Reference
- prefect-shell streaming implementation (shows chunk-based logging in shell_run_command): https://github.com/PrefectHQ/prefect-shell/blob/main/prefect_shell/commands.py
If you can use the sync path, Option A is simplest. If you need to stay async, Option B gives you real-time, per-line logs without clumping. Want me to adapt Option B to your exact command/env (e.g., Windows vs Linux, custom env vars)?Kal
04/16/2026, 12:56 AMasync def script_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, script_name: str
):
<http://logger.info|logger.info>(f"Sending Slack message for script '{script_name}' failure")
slack_webhook_block: SlackWebhook = await SlackWebhook.aload(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
with slack_webhook_block.raise_on_failure():
await slack_webhook_block.notify(
body=(
f"script `{script_name}` failed at "
f"{datetime.now(ZoneInfo("America/New_York")).strftime("%H:%M:%S %Z on %Y-%m-%d")}\n"
f"Flow run URL: {flow_run.ui_url}"
),
subject=f"script failure - {script_name}",
)
<http://logger.info|logger.info>(
f"Sent Slack message for script '{script_name}' failure"
)
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
if isinstance(e, NotificationError):
logger.error(f"Apprise NotificationError: {e.log}")
@flow(
name="script",
flow_run_name="{script_name}",
retries=2,
retry_delay_seconds=15,
timeout_seconds=14400,
)
def extract_and_load_task_with_failure_hook(script_name: str, env: str):
<http://logger.info|logger.info>(f"Running script - {script_name}")
shell_with_hook = shell_run_command.with_options(
name=f"Script - {script_name}",
on_failure=[
partial(
script_failure_notify_slack,
env=env,
script_name=script_name,
)
],
retries=2,
retry_delay_seconds=15,
)
# sequential execution; no concurrency
return shell_with_hook(f"poetry run python -m scripts.{script_name}")
@flow(
name="Split Scripts Chunk",
flow_run_name="{scripts_chunk}",
)
def chunk_subflow(scripts_chunk: list[str], env: str):
results = []
for x in scripts_chunk:
if x:
try:
# to use concurrency, call as .._hook.submit(x) and handle futures
result = extract_and_load_task_with_failure_hook(x, env)
results.append(result)
except Exception as e:
logger.error(
f"script '{x}' failed: {e}, continuing run", exc_info=True
)
return resultsMarvin
04/16/2026, 12:56 AMMarvin
04/16/2026, 12:58 AMfrom functools import partial
from datetime import datetime
from zoneinfo import ZoneInfo
from prefect import flow, task, State, Task
from prefect.logging import get_run_logger
from prefect.runtime import flow_run
from prefect.blocks.notifications import SlackWebhook, NotificationError
from prefect_shell import ShellOperation
async def script_failure_notify_slack(
task: Task, task_run, state: State, env: str, script_name: str
):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Sending Slack message for script '{script_name}' failure")
slack_webhook_block: SlackWebhook = await SlackWebhook.aload(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
with slack_webhook_block.raise_on_failure():
await slack_webhook_block.notify(
body=(
f"script `{script_name}` failed at "
f"{datetime.now(ZoneInfo('America/New_York')).strftime('%H:%M:%S %Z on %Y-%m-%d')}\n"
f"Flow run URL: {flow_run.ui_url}"
),
subject=f"script failure - {script_name}",
)
<http://logger.info|logger.info>(f"Sent Slack message for script '{script_name}' failure")
except Exception as e:
logger.error(f"Error sending Slack notification: {e}", exc_info=True)
if isinstance(e, NotificationError):
logger.error(f"Apprise NotificationError: {e.log}")
@task
def run_script_sync(script_name: str):
# Real-time, per-line streaming
op = ShellOperation(
commands=[f"poetry run python -u -m scripts.{script_name}"],
stream_output=True,
# Encourage immediate flushing from Python children
env={"PYTHONUNBUFFERED": "1"},
)
# Raises on non-zero exit -> triggers on_failure hook
return op.run()
@flow(
name="script",
flow_run_name="{script_name}",
retries=2,
retry_delay_seconds=15,
timeout_seconds=14400,
)
def extract_and_load_task_with_failure_hook(script_name: str, env: str):
logger = get_run_logger()
<http://logger.info|logger.info>(f"Running script - {script_name}")
task_with_hook = run_script_sync.with_options(
name=f"Script - {script_name}",
on_failure=[
partial(
script_failure_notify_slack,
env=env,
script_name=script_name,
)
],
retries=2,
retry_delay_seconds=15,
)
# sequential execution; no concurrency
return task_with_hook(script_name)
@flow(
name="Split Scripts Chunk",
flow_run_name="{scripts_chunk}",
)
def chunk_subflow(scripts_chunk: list[str], env: str):
logger = get_run_logger()
results = []
for x in scripts_chunk:
if x:
try:
result = extract_and_load_task_with_failure_hook(x, env)
results.append(result)
except Exception as e:
logger.error(
f"script '{x}' failed: {e}, continuing run", exc_info=True
)
return results
If you need even snappier line emission from non-Python commands, you can wrap them with stdbuf -oL -eL inside the commands=[...].
Refs:
- Shell streaming implementation: https://github.com/PrefectHQ/prefect-shell/blob/main/prefect_shell/commands.py
- Slack webhook block: https://docs.prefect.io/latest/blocks-catalog/notifications#slackwebhookKal
04/16/2026, 2:32 PMMarvin
04/16/2026, 2:32 PMMarvin
04/16/2026, 2:36 PMprefect.yaml you can drop into your project to:
- Define multiple deployments that each pull from a different branch of the same repo
- Authenticate using a GitHubCredentials block (from the prefect-github collection)
- Install dependencies before Prefect imports your flow code (so imports don’t fail)
- Show both requirements.txt and Poetry-based installs
Notes before you use it
- Make sure your worker/image has:
- pip (and optionally poetry if you use the Poetry path)
- the prefect-github collection installed: pip install prefect-github
- Save a GitHubCredentials block first (e.g., name: gh-creds), then reference it below:
- prefect block register -m prefect_github
- Programmatically or via UI, create the GitHubCredentials block and name it gh-creds
Sample prefect.yaml
- Two deployments (prod vs staging) pulling different branches via git_clone + GitHubCredentials
- Requirements install via pip (recommended for ensuring install affects current interpreter)
- A third deployment shows Poetry export -> pip install
- Optional example using a GitHubRepository block via pull_with_block
```
---
prefect-version: "3.6.25"
name: scripts-project
description: "Deploy flows that import and run scripts directly, with per-branch git pulls and pre-import dependency installs."
# No build/push steps — workers will 'pull' at runtime then import flows
build: null
push: null
# DRY values
definitions:
repo_url: &repo_url "https://github.com/your-org/your-repo.git"
work_pool: &work_pool
name: process-pool
work_queue_name: default
job_variables: {}
# Global defaults (can be overridden per-deployment)
# If you want a default pull chain you can template it here and override 'branch' later.
# Leaving global pull empty so each deployment defines its own branch explicitly.
pull: []
deployments:
# 1) Production deployment from main branch + requirements.txt
- name: scripts-prod
entrypoint: flows/run_scripts_flow.py:run_scripts_flow
description: "Run scripts flow (prod) from main branch"
tags: ["prod"]
parameters:
env: "prod"
work_pool: *work_pool
pull:
- prefect.deployments.steps.git_clone:
id: clone
repository: *repo_url
branch: "main"
# Use a GitHubCredentials block saved as 'gh-creds'
credentials: "{{ prefect.blocks.github-credentials.gh-creds }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ clone.directory }}"
requirements_file: "requirements.txt"
# 2) Staging deployment from develop branch + requirements.txt
- name: scripts-staging
entrypoint: flows/run_scripts_flow.py:run_scripts_flow
description: "Run scripts flow (staging) from develop branch"
tags: ["staging"]
parameters:
env: "staging"
work_pool: *work_pool
pull:
- prefect.deployments.steps.git_clone:
id: clone
repository: *repo_url
branch: "develop"
credentials: "{{ prefect.blocks.github-credentials.gh-creds }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ clone.directory }}"
requirements_file: "requirements.txt"
# 3) Development deployment from feature branch using Poetry export -> pip install
# This ensures dependencies install into the running interpreter before code import.
- name: scripts-dev-poetry
entrypoint: flows/run_scripts_flow.py:run_scripts_flow
description: "Run scripts flow (dev) from feature branch using Poetry export"
tags: ["dev", "poetry"]
parameters:
env: "dev"
work_pool: *work_pool
pull:
- prefect.deployments.steps.git_clone:
id: clone
repository: *repo_url
branch: "feature/my-feature"
credentials: "{{ prefect.blocks.github-credentials.gh-creds }}"
- prefect.deployments.steps.utility.run_shell_script:
script: |Marvin
04/16/2026, 2:36 PMpoetry install
# Using Poetry's virtualenv won't affect the interpreter Prefect uses to import flows.
# 4) Optional: Use a GitHubRepository block (from prefect-github) that already includes URL + credentials
# Create and save a 'GitHubRepository' block named 'repo-block' (with attached GitHubCredentials)
# Then Prefect will pull from that block; you can still install requirements after.
- name: scripts-using-repo-block
entrypoint: flows/run_scripts_flow.py:run_scripts_flow
description: "Run scripts via GitHubRepository block"
tags: ["repo-block"]
parameters:
env: "dev"
work_pool: *work_pool
pull:
- prefect.deployments.steps.pull_with_block:
block: "{{ prefect.blocks.github-repository.repo-block }}"
# Optional override parameters (if supported by your block version), otherwise
# create separate blocks per branch (e.g., repo-block-main, repo-block-develop).
# branch: "develop"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_with_block.directory }}"
requirements_file: "requirements.txt"
```
Why this works for “install before import”
- Workers execute the pull steps (clone + install) before loading your entrypoint. This ensures your flow and any scripts it imports have their dependencies ready.
- For Poetry: Prefect imports flow code into the current Python interpreter. Installing into a Poetry-managed venv will not affect that interpreter. The safest path is Poetry export to requirements + pip install into the running interpreter (shown above).
Tips
- If your repo is a Python package, you can also do pip install -e . in place of requirements to ensure intra-repo imports resolve:
- Replace the install step with:
- run_shell_script:
script: |
set -euxo pipefail
cd "{{ clone.directory }}"
python -m pip install --upgrade pip
python -m pip install -e .
- Large/private repos or multi-submodule repos: add include_submodules: true to git_clone.
- If you prefer pinning to SHAs, use commit_sha instead of branch on the git_clone step.
Docs/Refs
- Deployment steps (git clone, pip install, run shell): https://docs.prefect.io/latest/guides/deployment/#deployment-steps
- GitHub credentials and repository blocks (prefect-github): https://github.com/PrefectHQ/prefect-githubKal
04/16/2026, 2:51 PMMarvin
04/16/2026, 2:51 PMMarvin
04/16/2026, 2:52 PM---
prefect-version: "3.6.25"
name: multi-pool-project
# DRY definitions for work pools
definitions:
process_pool: &process_pool
name: process-pool
work_queue_name: default
job_variables:
env:
LOG_LEVEL: INFO
docker_pool: &docker_pool
name: docker-pool
work_queue_name: default
job_variables:
image: "your-registry/your-image:py3.11"
env:
PIP_INDEX_URL: "<https://pypi.org/simple>"
k8s_pool: &k8s_pool
name: k8s-pool
work_queue_name: default
job_variables:
image: "your-registry/your-image:py3.11"
namespace: "prefect"
env:
LOG_LEVEL: DEBUG
# Each deployment picks the pool it should run on
deployments:
- name: flow-on-process
entrypoint: flows/my_flow.py:my_flow
work_pool: *process_pool
- name: flow-on-docker
entrypoint: flows/my_flow.py:my_flow
work_pool: *docker_pool
- name: flow-on-k8s
entrypoint: flows/my_flow.py:my_flow
work_pool: *k8s_pool
Notes
- job_variables must match the base job template for the pool type (e.g., Docker/Kubernetes commonly support image/env/etc.; Process supports env/working_dir/etc.).
- You can also override work_pool per deployment without anchors:
- name: flow-X
work_pool:
name: another-pool
work_queue_name: high-priority
job_variables: { image: "..." }
Make sure your work pools exist before you deploy
- Create them once (examples):
prefect work-pool create "process-pool" --type process
prefect work-pool create "docker-pool" --type docker
prefect work-pool create "k8s-pool" --type kubernetes
Then run prefect deploy and each deployment will be registered against its specified pool.Kal
04/16/2026, 3:11 PMprefect-version: "3.6.25"
name: data-platform
description: "Deploy flows that import and run scripts directly, with per-branch git pulls and pre-import dependency installs."
# No build/push steps — workers will 'pull' at runtime then import flows
build: null
push: null
definitions:
kal_testing_pool: &kal_testing
name: "kal-testing"
work_queue_name: default
job_variables: {}
deployments:
- name: "Kal Testing With prefect.yaml"
entrypoint: "data_loaders/prefect_jobs_entrypoint.py:kal_testing_with_prefect_yaml"
description: "Testing using prefect.yaml deploy, direct load scripts"
tags: ["staging"]
work_pool: *kal_testing
pull:
- prefect.deployments.steps.pull_with_block:
block: "{{ prefect.blocks.github-repository.data-platform-github-kal }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_with_block.directory }}"
requirements_file: "requirements.txt"
However, when running "prefect deploy", it generates an error about "No such file or directory" for the entrypoint. It's trying to load it from the base directory of the current repository I am deploying from.Marvin
04/16/2026, 3:11 PMMarvin
04/16/2026, 3:14 PMprefect deploy validates and imports the entrypoint file on your local machine at deploy time. Pull steps (like pull_with_block) only run later on the worker, so they can’t make the entrypoint available during the deploy command.
What to do instead
Option A — Run deploy from the repo that has the entrypoint
- Ensure the file exists locally relative to where you run the command (or use an absolute path).
- Put prefect.yaml at the root of that repo (or pass --entrypoint that points to a local file).
- Keep your pull steps so workers also clone at run time for fresh code and dependency install.
Your YAML can stay the same, but add a working directory step for runtime:
deployments:
- name: "Kal Testing With prefect.yaml"
entrypoint: "data_loaders/prefect_jobs_entrypoint.py:kal_testing_with_prefect_yaml"
work_pool: *kal_testing
pull:
- prefect.deployments.steps.pull_with_block:
block: "{{ prefect.blocks.github-repository.data-platform-github-kal }}"
- prefect.deployments.steps.set_working_directory:
directory: "{{ pull_with_block.directory }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_with_block.directory }}"
requirements_file: "requirements.txt"
Then from the repo root (where data_loaders/prefect_jobs_entrypoint.py exists), run:
prefect deploy
or
prefect deploy data_loaders/prefect_jobs_entrypoint.py:kal_testing_with_prefect_yaml
Option B — Use an absolute entrypoint path
- Point entrypoint to an absolute path that exists on the machine running prefect deploy.
Option C — Switch to Python API with from_source (if you must deploy from a “control” repo)
- Use flow.from_source(...).deploy(...) with a `GitHubRepository`/`GitHubCredentials` so Prefect doesn’t need the local file at deploy time. This avoids the YAML entrypoint validation entirely. If you go this route, make sure dependencies are available on the worker image or run a pip install via your job image entrypoint.
Two small gotchas to check
- In YAML, the anchor should be &kal_testing (not &kal_testing). Your snippet looks HTML-escaped.
- The pull_with_block step sets up the repo at runtime only; it does not influence deploy-time resolution of entrypoint.
Docs
- Deployments and steps: https://docs.prefect.io/latest/guides/deployment/#deployment-stepsKal
04/16/2026, 3:30 PMMarvin
04/16/2026, 3:30 PMMarvin
04/16/2026, 3:31 PM---
prefect-version: "3.6.25"
name: data-platform
build: null
push: null
deployments:
- name: my-deploy
entrypoint: flows/my_flow.py:my_flow
work_pool:
name: process-pool
work_queue_name: default
job_variables: {}
pull:
- prefect.deployments.steps.git_clone:
id: clone
repository: "<https://github.com/your-org/your-repo.git>"
branch: "main"
credentials: "{{ prefect.blocks.github-credentials.gh-creds }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ clone.directory }}"
requirements_file: "requirements.txt"
Docs: https://docs.prefect.io/latest/guides/deployment/#deployment-steps
- Bake dependencies into your runtime image (Docker/Kubernetes pools)
- Pass an image to deploy(), and put your pip install in the Dockerfile so they’re present before import:
from prefect import flow
from prefect.docker import DockerImage
@flow
def my_flow(): ...
if __name__ == "__main__":
my_flow.from_source(
source="<https://github.com/your-org/your-repo.git>",
entrypoint="flows/my_flow.py:my_flow",
).deploy(
name="my-deploy",
work_pool_name="docker-pool",
image=DockerImage(name="your-registry/your-image", tag="py3.11"),
build=True, # build the image at deploy time
push=True, # push it to your registry
)
Then your Dockerfile ensures deps exist before Prefect loads your flow. Docs: https://docs.prefect.io/latest/guides/deployment/#using-custom-images
- Advanced: customize a work pool’s base job template to run a pre-start script
- For Docker/K8s pools, you can supply a custom base job template that runs a small script to pip install from the pulled repo path, then start the Prefect engine. This is more advanced and pool-type specific:
prefect work-pool create "docker-pool" --type docker --base-job-template ./base-job-template.json
Docs: https://docs.prefect.io/latest/concepts/work-pools/
If you want to stay with flow.from_source(...).deploy(...) and still guarantee install-before-import, the two robust approaches are:
- Use prefect.yaml with pull steps (clone + pip_install_requirements)
- Use a custom image that already has the dependencies
Happy to help convert your current from_source deployment into a tiny prefect.yaml that adds just the pull steps while keeping the rest of your setup the same.Kal
04/16/2026, 3:38 PMMarvin
04/16/2026, 3:38 PMMarvin
04/16/2026, 3:39 PMblock=.... The pull_with_block step takes two explicit arguments: block_type_slug and block_document_name.
Corrected snippet for your case
- Uses your GitHubRepository block named data-platform-github-kal (type slug: github-repository)
- Sets the working directory to the pulled repo
- Installs requirements before Prefect imports your entrypoint
prefect-version: "3.6.25"
name: data-platform
description: "Deploy flows that import and run scripts directly, with per-branch git pulls and pre-import dependency installs."
build: null
push: null
definitions:
kal_testing_pool: &kal_testing
name: "kal-testing"
work_queue_name: default
job_variables: {}
deployments:
- name: "Kal Testing With prefect.yaml"
entrypoint: "data_loaders/prefect_jobs_entrypoint.py:kal_testing_with_prefect_yaml"
description: "Testing using prefect.yaml deploy, direct load scripts"
tags: ["staging"]
work_pool: *kal_testing
pull:
- prefect.deployments.steps.pull_with_block:
block_type_slug: "github-repository"
block_document_name: "data-platform-github-kal"
- prefect.deployments.steps.set_working_directory:
directory: "{{ pull_with_block.directory }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_with_block.directory }}"
requirements_file: "requirements.txt"
Checklist
- Ensure the GitHubRepository block exists with name data-platform-github-kal and is of type github-repository.
- Your worker environment must have the prefect-github collection installed so it can load the block:
- pip install prefect-github
- If your requirements file isn’t at the repo root, update requirements_file accordingly.
- Make sure the anchor is not HTML-escaped (&kal_testing not &kal_testing).
References
- Deployment steps guide (git/pip/install): https://docs.prefect.io/latest/guides/deployment/#deployment-steps
- prefect-github (GitHubRepository/GitHubCredentials): https://github.com/PrefectHQ/prefect-githubKal
04/16/2026, 3:51 PMMarvin
04/16/2026, 3:51 PMMarvin
04/16/2026, 3:52 PMprefect-version: "3.6.25"
name: data-platform
build: null
push: null
definitions:
kal_testing_pool: &kal_testing
name: "kal-testing"
work_queue_name: default
job_variables: {}
deployments:
- name: "Kal Testing With prefect.yaml"
entrypoint: "data_loaders/prefect_jobs_entrypoint.py:kal_testing_with_prefect_yaml"
description: "Testing using prefect.yaml deploy, direct load scripts"
tags: ["staging"]
work_pool: *kal_testing
pull:
- prefect.deployments.steps.pull_with_block:
id: pull_code
block_type_slug: "github-repository"
block_document_name: "data-platform-github-kal"
- prefect.deployments.steps.set_working_directory:
id: set_cwd
directory: "{{ pull_code.directory }}"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_code.directory }}"
requirements_file: "requirements.txt"
Simpler version (no set_working_directory)
- This works fine if your entrypoint path is relative to the project root in the pulled repo. The worker’s current directory doesn’t need to change as long as you feed absolute/relative paths correctly to later steps.
deployments:
- name: "Kal Testing With prefect.yaml"
entrypoint: "data_loaders/prefect_jobs_entrypoint.py:kal_testing_with_prefect_yaml"
work_pool: *kal_testing
pull:
- prefect.deployments.steps.pull_with_block:
id: pull_code
block_type_slug: "github-repository"
block_document_name: "data-platform-github-kal"
- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_code.directory }}"
requirements_file: "requirements.txt"
Notes
- pull_with_block returns only one templatable key: directory. Always reference it via the step id, e.g., {{ pull_code.directory }}.
- Ensure your worker has the prefect-github collection installed so it can load the GitHubRepository block: pip install prefect-github.
Refs
- Steps and templating: https://docs.prefect.io/latest/guides/deployment/#deployment-stepsKal
04/17/2026, 2:49 PMpip install output. Can these be formatted to JSON easily?Marvin
04/17/2026, 2:49 PMMarvin
04/17/2026, 2:52 PMformatters:
json:
class: prefect.logging.formatters.JsonFormatter
format: default # or "pretty" for indented
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: 0
formatter: json
stream: <ext://sys.stderr>
loggers:
# Prefect’s deployment steps use this logger
deployment:
level: INFO
handlers: [console_json]
propagate: false
root:
level: WARNING
handlers: [console_json]
Then either:
- Point Prefect at this config
- export PREFECT_LOGGING_SETTINGS_PATH=/path/to/logging.yml
- Or override at runtime
- export PREFECT_LOGGING_HANDLERS_CONSOLE_FORMATTER=json
- export PREFECT_LOGGING_FORMATTERS_JSON_CLASS=prefect.logging.formatters.JsonFormatter
- export PREFECT_LOGGING_FORMATTERS_JSON_FORMAT=default
What will still not be JSON
- pip output from pip_install_requirements and any run_shell_script output. Those are streamed directly to stdout/stderr by the step implementations (they set stream_output=True and bind to sys.stdout/sys.stderr). They bypass Prefect’s logging and cannot be JSON-formatted by Prefect’s formatter.
Workarounds for pip/subprocess output
- Suppress streaming and only log a summary:
- Use a custom step (run_shell_script with stream_output: false or a modified script) that captures output and then logs it via get_run_logger (JSON formatted), but you’ll lose real-time streaming.
- Wrap pip in a small Python script that reads lines from the subprocess and emits JSON logs line-by-line via Prefect’s logger.
- At the infrastructure level, pipe deploy output into a parser, but since the subprocess output is not JSON, you’ll only get partial benefits.
If you want, I can share a tiny custom step snippet that runs pip as a subprocess, captures lines, and logs each line via Prefect’s logger so it renders in JSON (with the trade-off that you won’t see native colored pip output).Kal
04/17/2026, 3:07 PMPREFECT_LOGGING_SETTINGS_PATH set up. When a job is submitted, the first few lines about submitting the flow come through as JSON (from prefect.flow_runs.worker) but then the logs as the deployment defined in prefect.yaml are processed are not sent as JSON.Marvin
04/17/2026, 3:07 PMMarvin
04/17/2026, 3:09 PM- prefect.deployments.steps.utility.run_shell_script:
id: pip_install
# Run pip but do not stream raw output
script: |
python - <<'PY'
import subprocess, sys
from prefect.logging import get_logger
logger = get_logger("deployment") # picked up by your JSON config
p = subprocess.Popen(
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
cwd="{{ pull_code.directory }}",
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
)
for line in p.stdout:
<http://logger.info|logger.info>(line.rstrip())
sys.exit(p.wait())
PY
directory: "{{ pull_code.directory }}"
- Trade-off: you lose native colored pip output, but gain JSON per line.
Option B: Patch the environment to route step messages through logging
- Not available via config alone today. The CLI’s deploy/build-time step messages intentionally use console printing. To make those JSON you’d need a code change to pass a logger into run_steps or change those print calls to use a logger (which then honors your JSON formatter).
- If this matters a lot, I can open a feature request or share a small patch diff showing where to inject a logger into run_steps during deploy.
Sanity checks you can do
- Confirm your JSON formatter is bound to the relevant loggers/handlers in your logging.yml:
- Ensure the console handler uses your JsonFormatter
- Add explicit logger sections if needed:
- prefect.deployments.steps.core
- deployment
- prefect.flow_runs
- Example snippet to pin step loggers to JSON:
loggers:
deployment:
level: INFO
handlers: [console_json]
propagate: false
prefect.deployments.steps.core:
level: INFO
handlers: [console_json]
propagate: false
Bottom line
- You can get JSON for all logs that go through logging by adjusting logging.yml.
- pip output and deploy progress emitted via console won’t be JSON without switching to a capture-and-log approach (Option A) or modifying Prefect to send those messages through the logger (Option B).Kal
04/17/2026, 3:20 PMprefect as a logger so everything nested under that should be JSON so prefect.deployments.steps.core shouldn't be needed. I added the deployment logger but nothing actually changed in the logs as a result. The deployment steps from the prefect.yaml deployment did not show up as JSON.Marvin
04/17/2026, 3:20 PMMarvin
04/17/2026, 3:22 PM- prefect.deployments.steps.pip_install_requirements:
directory: "{{ pull_code.directory }}"
requirements_file: "requirements.txt"
stream_output: false
- Trade-off: you won’t see live pip output (you’ll still get step start/finish messages).
Option B: Replace pip_install_requirements with a custom step that logs via Prefect
- Write a tiny step (in a package installed on your worker image) that runs pip, reads lines, and logs via Prefect’s logger so each line is JSON. Example:
Python (ship this with your worker image):
# my_steps/pip_json.py
import subprocess, sys, shlex
from prefect.logging import get_logger
def pip_install_json(directory=None, requirements_file="requirements.txt"):
logger = get_logger("deployment") # picked up by your JSON config
cmd = [sys.executable, "-m", "pip", "install", "-r", requirements_file]
<http://logger.info|logger.info>("pip_start cmd=%s cwd=%s", " ".join(shlex.quote(x) for x in cmd), directory or ".")
p = subprocess.Popen(
cmd, cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
)
for line in p.stdout:
<http://logger.info|logger.info>(line.rstrip())
code = p.wait()
if code != 0:
raise RuntimeError(f"pip exited with {code}")
<http://logger.info|logger.info>("pip_done returncode=%s", code)
return {"returncode": code}
prefect.yaml:
- my_steps.pip_json:pip_install_json:
directory: "{{ pull_code.directory }}"
requirements_file: "requirements.txt"
- This keeps the JSON line guarantee because it uses Prefect’s logger instead of streaming to stdout.
Option C: Avoid pull-time installs (no non-JSON output to begin with)
- Bake dependencies into your worker image (Docker/K8s pools). Then you can remove the pip step entirely and you’ll only see Prefect logs (which your JSON config already formats).
Optional: Upgrade note
- Newer 3.x versions have improved step logging paths in more places, but pip output is still a raw subprocess stream and will remain non-JSON unless you adopt Option A or B.
If you share where you’re aggregating logs (worker stdout vs Prefect API), I can tailor the config further. If you want, I can also provide a minimal wheel/packaging snippet so you can ship the custom step cleanly with your worker image.Kal
04/17/2026, 3:39 PMMarvin
04/17/2026, 3:39 PMMarvin
04/17/2026, 3:40 PMprefect logger (or deployment) won’t apply.
- Reference (run_steps implementation): https://github.com/PrefectHQ/prefect/blob/main/src/prefect/deployments/steps/core.py
What you can do
- For deploy-time (prefect deploy) step progress: there isn’t a config-based switch to force those messages into JSON without modifying Prefect to pass a logger into run_steps.
- For runtime pull steps: in many cases they already log via the flow-run logger and will be JSON in the Prefect UI logs; if you’re watching worker stdout, you may still see console prints mixed in.
Workable approaches if you must have JSON now
- Suppress non-JSON progress noise and rely on Prefect UI/API logs (already JSON with your logging config).
- Wrap steps with your own minimal “announce” step that uses Prefect’s logger to emit JSON lines before/after each step (not pretty, but effective).
- If you control the code path that triggers run_steps, pass a logger to it so messages route through logging instead of console printing.
If you want, I can:
- Share a tiny helper “announce” step you can add around your pull steps to emit JSON progress lines, or
- Open a feature request proposing that run_steps always prefer a logger (when available) so these progress lines can be JSON-formatted out of the box.Kal
04/17/2026, 4:07 PMapprise about sending a Slack notification:
{
"name": "apprise",
"msg": "Sent Slack notification.",
"args": [],
"levelname": "INFO",
"levelno": 20,
"pathname": "/usr/local/lib/python3.12/site-packages/apprise/plugins/slack.py",
"filename": "slack.py",
"module": "slack",
"exc_info": null,
"exc_text": null,
"stack_info": null,
"lineno": 701,
"funcName": "send",
"created": 1776439082.1693785,
"msecs": 169,
"relativeCreated": 130207.64636993408,
"thread": 123275044718272,
"threadName": "RunSyncEventLoopThread",
"processName": "MainProcess",
"process": 36,
"taskName": "Task-84",
"message": "Sent Slack notification.",
"severity": "INFO"
}
I have a config set in the logging.yml that doesn't seem to be removing this INFO level message:
loggers:
...
apprise:
level: WARNING
handlers: [json]Marvin
04/17/2026, 4:07 PMMarvin
04/17/2026, 4:08 PMversion: 1
disable_existing_loggers: false
formatters:
json:
class: prefect.logging.formatters.JsonFormatter
format: default
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: DEBUG
formatter: json
stream: <ext://sys.stderr>
null:
class: logging.NullHandler
level: WARNING
loggers:
apprise:
level: WARNING
handlers: [null] # swallow apprise logs at WARNING+
propagate: false # prevent reaching root
# Optionally cover children explicitly (belt & suspenders)
apprise.plugins:
level: WARNING
handlers: [null]
propagate: false
apprise.plugins.slack:
level: WARNING
handlers: [null]
propagate: false
root:
level: INFO
handlers: [console_json]
Notes
- version and disable_existing_loggers are important for dictConfig. Without them, logger sections can be ignored or behave unexpectedly.
- Using propagate: false ensures nothing bubbles up to the root handler (which is why you still saw INFO).
- If your existing handler is named something else (e.g., console_json), keep using it for root/prefect; the NullHandler is only for apprise.
- Make sure PREFECT_LOGGING_SETTINGS_PATH is set in the worker’s environment (where the flow actually runs), not just your shell.
- If you still see the message, try also defining the child logger names (apprise.plugins, apprise.plugins.slack) as above; some packages log with child names.
If you want to keep apprise warnings/errors but hide INFO only, the above does exactly that (since INFO < WARNING). If you prefer to drop apprise entirely, set the apprise logger level to CRITICAL.Kal
04/17/2026, 4:11 PMMarvin
04/17/2026, 4:11 PMMarvin
04/17/2026, 4:11 PMversion: 1
disable_existing_loggers: false
formatters:
json:
class: prefect.logging.formatters.JsonFormatter
format: default
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: DEBUG
formatter: json
stream: <ext://sys.stderr>
loggers:
apprise:
level: WARNING # filter out INFO at the logger
handlers: [] # no direct handler here
propagate: true # WARNING+ will go to the root handler
root:
level: INFO
handlers: [console_json]
B) Handle apprise directly (don’t propagate), still JSON
loggers:
apprise:
level: WARNING
handlers: [console_json] # send WARNING+ to JSON console
propagate: false # prevent duplicates via root
If you still see apprise INFO after this, double-check:
- logging.yml includes version: 1
- PREFECT_LOGGING_SETTINGS_PATH is set in the worker’s environment (not just your shell)
- No other config later overrides the apprise logger
The JSON you pasted shows name="apprise" with level INFO, which usually means the apprise logger level wasn’t actually raised to WARNING at runtime. The two snippets above will suppress INFO while preserving WARNING/ERROR in JSON.Kal
04/17/2026, 4:13 PMMarvin
04/17/2026, 4:13 PMMarvin
04/17/2026, 4:14 PM# my_logging_filters.py
import logging
class DropAppriseInfo(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# Drop INFO (and below) from any 'apprise' logger
return not (record.name.startswith("apprise") and record.levelno < logging.WARNING)
2) Reference that filter in your logging.yml and attach it to the handlers that emit your logs (console_json, api, worker_api, etc.)
version: 1
disable_existing_loggers: true
formatters:
json:
class: prefect.logging.formatters.JsonFormatter
format: default
filters:
drop_apprise_info:
(): my_logging_filters.DropAppriseInfo
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: DEBUG
formatter: json
filters: [drop_apprise_info]
stream: <ext://sys.stderr>
# If you send logs to the API, attach the filter there too
api:
class: prefect.logging.handlers.APILogHandler
level: DEBUG
filters: [drop_apprise_info]
loggers:
# You can keep this minimal; the handler-level filter already does the work
apprise:
level: WARNING
handlers: []
propagate: true
root:
level: INFO
handlers: [console_json, api]
Why this works
- Even if apprise sets its own levels/handlers later or propagates to root, the handler-level filter drops any apprise records below WARNING right before emission.
- Setting disable_existing_loggers: true ensures any pre-attached third‑party handlers are cleared unless you re-declare them.
Quick verification
- In a test flow or shell, check the effective level and handlers:
- import logging; print(logging.getLogger('apprise').getEffectiveLevel())
- Ensure your worker can import my_logging_filters and PREFECT_LOGGING_SETTINGS_PATH points to this logging.yml.
If you’d rather not ship a custom filter file, you can also:
- Apply the filter only to the API handler (if you only care about API-side logs)
- Or, as a blunt instrument, set apprise to propagate: false and give it a handler with level WARNING (but since that didn’t work in your env, the handler-level filter is the reliable path)Kal
04/17/2026, 9:23 PMTask run failed with %s: %r - Retry %s/%s will start %s
and an args property as follows:
`["exception", {"__exc_type__":"builtins.AttributeError","message":"module 'data_loaders.google_sheets has no attribute'runMarvin
04/17/2026, 9:23 PMMarvin
04/17/2026, 9:24 PM# my_formatters.py
import logging, orjson
class CleanJsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"name": record.name,
"levelname": record.levelname,
"message": record.getMessage(), # final, formatted text
"created": record.created,
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return orjson.dumps(payload).decode()
Then in logging.yml:
version: 1
disable_existing_loggers: false
formatters:
clean_json:
(): my_formatters.CleanJsonFormatter
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: DEBUG
formatter: clean_json
stream: <ext://sys.stderr>
loggers:
prefect:
level: INFO
handlers: [console_json]
propagate: false
root:
level: WARNING
handlers: [console_json]
This ensures:
- message is the formatted string you expect
- msg/args won’t appear in your emitted JSON
If you still see placeholders after this, it means the handler emitting those lines isn’t using your JSON formatter (double-check PREFECT_LOGGING_SETTINGS_PATH is set in the worker runtime env and that the specific handler/logger is bound to your formatter).Kal
04/17/2026, 9:53 PMjson:
'()': prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
stype: '%'
It seems like the message attribute is not actually being emitted at all in the log, only msg.Marvin
04/17/2026, 9:53 PMMarvin
04/17/2026, 9:56 PMformatters:
json:
(): prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
style: '%'
2) Why “message” can be missing on some records
- Prefect’s JsonFormatter normally includes a “message” field computed via record.getMessage().
- In your example, the template has 5 placeholders:
"Task run failed with %s: %r - Retry %s/%s will start %s"
but the args payload you showed looks incomplete. If getMessage() raises during formatting (e.g., arg count/type mismatch), many formatters will fall back to emitting the raw msg/args; that’s why you’re seeing only “msg” in those JSONs.
If fixing “style” doesn’t restore “message” for those failure logs, add a robust fallback formatter that always emits a formatted message even when interpolation fails:
Python (make this importable by the worker):
# my_formatters.py
import logging, orjson
from prefect.logging.formatters import JsonFormatter
class RobustJsonFormatter(JsonFormatter):
def format(self, record: logging.LogRecord) -> str:
# Try normal JSON formatting first
try:
return super().format(record)
except Exception:
# If interpolation fails, build a safe message
try:
safe_message = record.getMessage()
except Exception:
safe_message = f"{record.msg} | args={record.args!r}"
payload = {
"name": record.name,
"levelname": record.levelname,
"message": safe_message,
"created": record.created,
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return orjson.dumps(payload).decode()
logging.yml:
formatters:
json:
(): my_formatters.RobustJsonFormatter
fmt: default
dmft: null
style: '%'
This keeps your normal Prefect JSON shape when formatting succeeds, and guarantees a “message” string when it doesn’t. If you still see odd placeholders after this, it likely means another handler/formatter is emitting those lines — double-check PREFECT_LOGGING_SETTINGS_PATH is set in the worker runtime and that your console/api handlers use this formatter.Kal
04/20/2026, 2:15 PMjson:
'()': prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
style: '%'
Additionally, it looks like sometimes that log message looks correct when it's a retries exhausted message but fails when it's a retrying one. See samples of each type below:
{"name":"prefect.task_runs","msg":"Task run failed with exception: Exception('Kal Test Exception') - Retries are exhausted","args":[],"levelname":"ERROR",
...
}Marvin
04/20/2026, 2:15 PMKal
04/20/2026, 2:15 PM{"name":"prefect.task_runs","msg":"Task run failed with %s: %r - Retry %s/%s will start %s","args":["exception",{"__exc_type__":"builtins.Exception","message":"Kal Test Exception"},2,2,"30 second(s) from now"],"levelname":"INFO",
...
}Marvin
04/20/2026, 2:15 PMMarvin
04/20/2026, 2:18 PM# my_formatters.py
from prefect.logging.formatters import JsonFormatter
class JsonFormatterWithMessage(JsonFormatter):
def format(self, record):
# Let Prefect build the dict first via parent logic,
# then inject the formatted 'message' before serialization.
# Easiest is to copy their logic and add message; but to avoid
# re-implementing internals, we call super(), parse, add, re-dump.
s = super().format(record) # JSON string
import orjson
d = orjson.loads(s) # to dict
try:
d["message"] = record.getMessage() # add formatted message
except Exception:
# fall back if interpolation fails
d["message"] = f"{record.msg} | args={record.args!r}"
return orjson.dumps(d).decode()
logging.yml:
formatters:
json:
(): my_formatters.JsonFormatterWithMessage
fmt: default
dmft: null
style: '%'
Option 2 — Replace with a clean custom JSON formatter
- If you don’t need every LogRecord field, emit a tighter schema:
# my_formatters.py
import orjson
from logging import Formatter
class CleanJsonFormatter(Formatter):
def format(self, record):
try:
message = record.getMessage()
except Exception:
message = f"{record.msg} | args={record.args!r}"
payload = {
"name": record.name,
"levelname": record.levelname,
"message": message,
"created": record.created,
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return orjson.dumps(payload).decode()
logging.yml:
formatters:
json:
(): my_formatters.CleanJsonFormatter
Option 3 — Keep Prefect’s formatter and adjust your downstream consumer
- If your log pipeline can be configured to use “message” when present and otherwise compute it as msg % args, you can avoid changing Prefect. But most collectors don’t do this automatically.
Recommendation
- If you want minimal change and full compatibility with the rest of Prefect’s JSON shape, go with Option 1 (JsonFormatterWithMessage). It preserves all existing fields and just adds “message” with the formatted text so you don’t see placeholders like “Task run failed with %s…”.
Also make sure:
- In your logging.yml, use “style”, not “stype”.
- Handlers that emit these task logs are actually bound to this formatter (console_json, api, worker_api, etc.).
- PREFECT_LOGGING_SETTINGS_PATH is set in the worker runtime environment.Marvin
04/20/2026, 2:18 PMformatters:
json:
(): prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
style: '%'
This is working as designed, but Prefect’s JsonFormatter simply doesn’t emit a “message” field.
Ways to get a final, formatted “message” string
Pick one of these, depending on what’s easiest for your stack:
A) Add a custom formatter that extends Prefect’s and injects message
- Keep the same JSON structure but add message=record.getMessage().
# my_formatters.py
from prefect.logging.formatters import JsonFormatter
class JsonFormatterWithMessage(JsonFormatter):
def format(self, record):
# Compute the final message once
try:
formatted = record.getMessage()
except Exception:
# If interpolation fails for any reason, fall back to raw msg/args
formatted = f"{record.msg} | args={record.args!r}"
# Call base to build the dict and serialize
out = super().format(record)
# out is a JSON string; quickest path is to re-serialize with the extra field
# but to avoid double parse/serialize, override JsonFormatter to expose the dict instead.
return out
A more efficient version that avoids double-serialization by copying Prefect’s code and injecting the field before dumping:
# my_formatters.py
import orjson
from prefect.logging.formatters import JsonFormatter, _make_record_serializable
class JsonFormatterWithMessage(JsonFormatter):
def format(self, record):
record_dict = record.__dict__.copy()
# Inject 'message' field
try:
record_dict["message"] = record.getMessage()
except Exception:
record_dict["message"] = f"{record.msg} | args={record.args!r}"
# Rest mirrors Prefect’s JsonFormatter
record_dict["severity"] = record.levelname
if record.exc_info:
record_dict["exc_info"] = self.formatException(record.exc_info)
try:
return self.serializer.dumps(record_dict).decode()
except Exception:
record_dict = _make_record_serializable(record_dict)
return self.serializer.dumps(record_dict).decode()
Switch your logging.yml to use it:
formatters:
json:
(): my_formatters.JsonFormatterWithMessage
fmt: default
dmft: null
style: '%'
B) Post-process logs in your collector
- If you can’t change the formatter, have your log pipeline treat:
- message = record.msg if record.args is empty
- else message = Python-style %-format of msg with args (careful with %r)
C) Change only the problematic call sites (least recommended)
- Not ideal since it requires patching Prefect internals to use f-strings everywhere.
Recommendation
- Option A is usually the most robust: it keeps Prefect’s JSON shape, adds “message” for human readability/search, and gracefully handles interpolation failures.
- After switching, you’ll see both msg (template), args, and message (final string). If you want to hide msg/args, you can also pop them before dumping.
If you want, I can share a drop-in version that also removes msg/args once message is present, so your JSON is cleaner.Kal
04/20/2026, 2:48 PMmsg key instead of message is fine.Marvin
04/20/2026, 2:48 PMMarvin
04/20/2026, 2:49 PM# my_logging_filters.py
import logging
class FormatMsgFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
try:
# Compute the final message once and store it back in msg
record.msg = record.getMessage()
record.args = () # prevent double-formatting downstream
except Exception:
# If anything goes wrong, leave msg/args untouched
pass
return True
2) Attach it to the handlers that emit your JSON (console/api)
- Keep using Prefect’s JsonFormatter; the filter will guarantee msg is already formatted when the formatter dumps record.dict.
Example logging.yml snippet:
version: 1
disable_existing_loggers: false
formatters:
json:
(): prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
style: '%'
filters:
format_msg:
(): my_logging_filters.FormatMsgFilter
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: DEBUG
formatter: json
filters: [format_msg]
stream: <ext://sys.stderr>
api:
class: prefect.logging.handlers.APILogHandler
level: DEBUG
formatter: json
filters: [format_msg]
loggers:
prefect:
level: INFO
handlers: [console_json, api]
propagate: false
root:
level: WARNING
handlers: [console_json]
Notes
- Make sure my_logging_filters.py is importable in the worker’s environment, and PREFECT_LOGGING_SETTINGS_PATH points to this logging.yml in that same environment.
- This safely handles both cases you showed:
- f-string logs (retries exhausted): msg already formatted, args empty
- %-formatted logs (retry scheduled): filter applies getMessage(), so msg becomes the final string and args is cleared
- If any downstream relies on args, be aware we clear it here to avoid double-formatting.
For reference on why message isn’t present by default, Prefect’s JsonFormatter serializes the raw LogRecord dict (msg/args), not record.getMessage():
- https://github.com/PrefectHQ/prefect/blob/main/src/prefect/logging/formatters.py
- Retry log call site for context: https://github.com/PrefectHQ/prefect/blob/main/src/prefect/task_engine.pyKal
04/20/2026, 3:58 PMimport contextlib
from io import StringIO
import logging
import os
# The root identifier needed to monitor 'apprise' logging
LOGGER_NAME = "apprise"
# Define a verbosity level that is a noisier then debug mode
logging.TRACE = logging.DEBUG - 1
# Define a verbosity level that is always used even when no verbosity is set
# from the command line. The idea here is to allow for deprecation notices
logging.DEPRECATE = logging.ERROR + 1
# Assign our Levels into our logging object
logging.addLevelName(logging.DEPRECATE, "DEPRECATION WARNING")
logging.addLevelName(logging.TRACE, "TRACE")
def trace(self, message, *args, **kwargs):
"""
Verbose Debug Logging - Trace
"""
if self.isEnabledFor(logging.TRACE):
self._log(logging.TRACE, message, args, **kwargs)
def deprecate(self, message, *args, **kwargs):
"""Deprication Warning Logging."""
if self.isEnabledFor(logging.DEPRECATE):
self._log(logging.DEPRECATE, message, args, **kwargs)
# Assign our Loggers for use in Apprise
logging.Logger.trace = trace
logging.Logger.deprecate = deprecate
# Create ourselve a generic (singleton) logging reference
logger = logging.getLogger(LOGGER_NAME)
class LogCapture:
"""A class used to allow one to instantiate loggers that write to memory
for temporary purposes. e.g.:
1. with LogCapture() as captured:
2.
3. # Send our notification(s)
4. aobj.notify("hello world")
5.
6. # retrieve our logs produced by the above call via our
7. # `captured` StringIO object we have access to within the `with`
8. # block here:
9. print(captured.getvalue())
"""
def __init__(
self,
path=None,
level=None,
name=LOGGER_NAME,
delete=True,
fmt="%(asctime)s - %(levelname)s - %(message)s",
):
"""Instantiate a temporary log capture object.
If a path is specified, then log content is sent to that file instead
of a StringIO object.
You can optionally specify a logging level such as <http://logging.INFO|logging.INFO> if you
wish, otherwise by default the script uses whatever logging has been
set globally. If you set delete to `False` then when using log files,
they are not automatically cleaned up afterwards.
Optionally over-ride the fmt as well if you wish.
"""
# Our memory buffer placeholder
self.__buffer_ptr = StringIO()
# Store our file path as it will determine whether or not we write to
# memory and a file
self.__path = path
self.__delete = delete
# Our logging level tracking
self.__level = level
self.__restore_level = None
# Acquire a pointer to our logger
self.__logger = logging.getLogger(name)
# Prepare our handler
self.__handler = (
logging.StreamHandler(self.__buffer_ptr)
if not self.__path
else logging.FileHandler(self.__path, mode="a", encoding="utf-8")
)
# Use the specified level, otherwise take on the already
# effective level of our logger
self.__handler.setLevel(
self.__level
if self.__level is not None
else self.__logger.getEffectiveLevel()
)
# Prepare our formatter
self.__handler.setFormatter(logging.Formatter(fmt))
def __enter__(self):
"""Allows logger manipulation within a 'with' block."""
if self.__level is not None:
# Temporary adjust our log level if required
self.__restore_level = self.__logger.getEffectiveLevel()
if self.__restore_level > self.__level:
# Bump our log level up for the duration of our `with`
self.__logger.setLevel(self.__level)
else:
# No restoration required
self.__restore_level = None
else:
# Do nothing but enforce that we have nothing to restore to
self.__restore_level = None
if self.__path:
# If a path has been identified, ensure we can write to the path
# and that the file exists
with open(self.__path, "a"):
os.utime(self.__path, None)
# Update our buffer pointer
self.__buffer_ptr = open(self.__path)
# Add our handler
self.__logger.addHandler(self.__handler)
# return our memory pointer
return self.__buffer_ptr
def __exit__(self, exc_type, exc_value, tb):
"""Removes the handler gracefully when the with block has completed."""
# Flush our content
self.__handler.flush()
self.__buffer_ptr.flush()
# Drop our handler
self.__logger.removeHandler(self.__handler)
if self.__restore_level is not None:
# Restore level
self.__logger.setLevel(self.__restore_level)
if self.__path:
# Close our file pointer
self.__buffer_ptr.close()
self.__handler.close()
if self.__delete:
with contextlib.suppress(OSError):
# Always remove file afterwards
os.unlink(self.__path)
return exc_type is NoneMarvin
04/20/2026, 3:58 PMcaptured StringIO object we have access to within the with 8 # block here 9 print(captured getvalue()) def __init__( self path=None level=None name=LOGGER_NAME delete=True fmt= %(asctime)s - %(levelname)s - %(message)s ) Instantiate a temporary log capture object If a path is specified then log content is sent to that file instead of a StringIO object You can optionally specify a logging level such as <http //logging INFO|logging INFO> if you wish otherwise by default the script uses whatever logging has been set globally If you set delete to False then when using log files they are not automatically cleaned up afterwards Optionally over-ride the fmt as well if you wish # Our memory buffer placeholder self __buffer_ptr = StringIO() # Store our file path as it will determine whether or not we write to # memory and a file self __path = path self __delete = delete # Our logging level tracking self __level = level self __restore_level = None # Acquire a pointer to our logger self __logger = logging getLogger(name) # Prepare our handler self __handler = ( logging StreamHandler(self __buffer_ptr) if not self __path else logging FileHandler(self __path mode= a encoding= utf-8 ) ) # Use the specified level otherwise take on the already # effective level of our logger self __handler setLevel( self __level if self __level is not None else self __logger getEffectiveLevel() ) # Prepare our formatter self __handler setFormatter(logging Formatter(fmt)) def __enter__(self) Allows logger manipulation within a 'with' block if selfKal
04/20/2026, 3:59 PM...
# The root identifier needed to monitor 'apprise' logging
LOGGER_NAME = "apprise"
...
# Create ourselve a generic (singleton) logging reference
logger = logging.getLogger(LOGGER_NAME)
class LogCapture:
"""A class used to allow one to instantiate loggers that write to memory
for temporary purposes. e.g.:
1. with LogCapture() as captured:
2.
3. # Send our notification(s)
4. aobj.notify("hello world")
5.
6. # retrieve our logs produced by the above call via our
7. # `captured` StringIO object we have access to within the `with`
8. # block here:
9. print(captured.getvalue())
"""
def __init__(
self,
path=None,
level=None,
name=LOGGER_NAME,
delete=True,
fmt="%(asctime)s - %(levelname)s - %(message)s",
):
"""Instantiate a temporary log capture object.
If a path is specified, then log content is sent to that file instead
of a StringIO object.
You can optionally specify a logging level such as <http://logging.INFO|logging.INFO> if you
wish, otherwise by default the script uses whatever logging has been
set globally. If you set delete to `False` then when using log files,
they are not automatically cleaned up afterwards.
Optionally over-ride the fmt as well if you wish.
"""
# Our memory buffer placeholder
self.__buffer_ptr = StringIO()
# Store our file path as it will determine whether or not we write to
# memory and a file
self.__path = path
self.__delete = delete
# Our logging level tracking
self.__level = level
self.__restore_level = None
# Acquire a pointer to our logger
self.__logger = logging.getLogger(name)
# Prepare our handler
self.__handler = (
logging.StreamHandler(self.__buffer_ptr)
if not self.__path
else logging.FileHandler(self.__path, mode="a", encoding="utf-8")
)
# Use the specified level, otherwise take on the already
# effective level of our logger
self.__handler.setLevel(
self.__level
if self.__level is not None
else self.__logger.getEffectiveLevel()
)
# Prepare our formatter
self.__handler.setFormatter(logging.Formatter(fmt))
def __enter__(self):
"""Allows logger manipulation within a 'with' block."""
if self.__level is not None:
# Temporary adjust our log level if required
self.__restore_level = self.__logger.getEffectiveLevel()
if self.__restore_level > self.__level:
# Bump our log level up for the duration of our `with`
self.__logger.setLevel(self.__level)
else:
# No restoration required
self.__restore_level = None
else:
# Do nothing but enforce that we have nothing to restore to
self.__restore_level = None
if self.__path:
# If a path has been identified, ensure we can write to the path
# and that the file exists
with open(self.__path, "a"):
os.utime(self.__path, None)
# Update our buffer pointer
self.__buffer_ptr = open(self.__path)
# Add our handler
self.__logger.addHandler(self.__handler)
# return our memory pointer
return self.__buffer_ptr
def __exit__(self, exc_type, exc_value, tb):
"""Removes the handler gracefully when the with block has completed."""
# Flush our content
self.__handler.flush()
self.__buffer_ptr.flush()
# Drop our handler
self.__logger.removeHandler(self.__handler)
if self.__restore_level is not None:
# Restore level
self.__logger.setLevel(self.__restore_level)
if self.__path:
# Close our file pointer
self.__buffer_ptr.close()
self.__handler.close()
if self.__delete:
with contextlib.suppress(OSError):
# Always remove file afterwards
os.unlink(self.__path)
return exc_type is NoneMarvin
04/20/2026, 3:59 PMcaptured StringIO object we have access to within the with 8 # block here 9 print(captured getvalue()) def __init__( self path=None level=None name=LOGGER_NAME delete=True fmt= %(asctime)s - %(levelname)s - %(message)s ) Instantiate a temporary log capture object If a path is specified then log content is sent to that file instead of a StringIO object You can optionally specify a logging level such as <http //logging INFO|logging INFO> if you wish otherwise by default the script uses whatever logging has been set globally If you set delete to False then when using log files they are not automatically cleaned up afterwards Optionally over-ride the fmt as well if you wish # Our memory buffer placeholder self __buffer_ptr = StringIO() # Store our file path as it will determine whether or not we write to # memory and a file self __path = path self __delete = delete # Our logging level tracking self __level = level self __restore_level = None # Acquire a pointer to our logger self __logger = logging getLogger(name) # Prepare our handler self __handler = ( logging StreamHandler(self __buffer_ptr) if not self __path else logging FileHandler(self __path mode= a encoding= utf-8 ) ) # Use the specified level otherwise take on the already # effective level of our logger self __handler setLevel( self __level if self __level is not None else self __logger getEffectiveLevel() ) # Prepare our formatter self __handler setFormatter(logging Formatter(fmt)) def __enter__(self) Allows logger manipulation within a 'with' block if self __level is not None # Temporary adjust our log level if required self __restore_level = self __logger getEffectiveLevel() if self __restore_level > self __level # Bump our log level up for the duration of our with self __logger setLevel(self __level) else # No restoration required self __restore_level = None else # Do nothing but enforce that we have nothing to restore to self __restore_level = None if self __path # If a path has been identified ensure we can write to the path # and that the file exists with open(self __path a ) os utime(self __path None) # Update our buffer pointer self __buffer_ptr = open(self __path) # Add our handler self __logger addHandler(self __handler) # return our memory pointer return self __buffer_ptr def __exit__(self exc_type exc_value tb) Removes the handler gracefully when the with block has completedKal
04/20/2026, 4:00 PM...
# The root identifier needed to monitor 'apprise' logging
LOGGER_NAME = "apprise"
...
# Create ourselve a generic (singleton) logging reference
logger = logging.getLogger(LOGGER_NAME)
class LogCapture:
"""A class used to allow one to instantiate loggers that write to memory
for temporary purposes. e.g.:
1. with LogCapture() as captured:
2.
3. # Send our notification(s)
4. aobj.notify("hello world")
5.
6. # retrieve our logs produced by the above call via our
7. # `captured` StringIO object we have access to within the `with`
8. # block here:
9. print(captured.getvalue())
"""
def __init__(
self,
path=None,
level=None,
name=LOGGER_NAME,
delete=True,
fmt="%(asctime)s - %(levelname)s - %(message)s",
):
# Our memory buffer placeholder
self.__buffer_ptr = StringIO()
# Store our file path as it will determine whether or not we write to
# memory and a file
self.__path = path
self.__delete = delete
# Our logging level tracking
self.__level = level
self.__restore_level = None
# Acquire a pointer to our logger
self.__logger = logging.getLogger(name)
# Prepare our handler
self.__handler = (
logging.StreamHandler(self.__buffer_ptr)
if not self.__path
else logging.FileHandler(self.__path, mode="a", encoding="utf-8")
)
# Use the specified level, otherwise take on the already
# effective level of our logger
self.__handler.setLevel(
self.__level
if self.__level is not None
else self.__logger.getEffectiveLevel()
)
# Prepare our formatter
self.__handler.setFormatter(logging.Formatter(fmt))
def __enter__(self):
"""Allows logger manipulation within a 'with' block."""
if self.__level is not None:
# Temporary adjust our log level if required
self.__restore_level = self.__logger.getEffectiveLevel()
if self.__restore_level > self.__level:
# Bump our log level up for the duration of our `with`
self.__logger.setLevel(self.__level)
else:
# No restoration required
self.__restore_level = None
else:
# Do nothing but enforce that we have nothing to restore to
self.__restore_level = None
if self.__path:
# If a path has been identified, ensure we can write to the path
# and that the file exists
with open(self.__path, "a"):
os.utime(self.__path, None)
# Update our buffer pointer
self.__buffer_ptr = open(self.__path)
# Add our handler
self.__logger.addHandler(self.__handler)
# return our memory pointer
return self.__buffer_ptr
def __exit__(self, exc_type, exc_value, tb):
"""Removes the handler gracefully when the with block has completed."""
# Flush our content
self.__handler.flush()
self.__buffer_ptr.flush()
# Drop our handler
self.__logger.removeHandler(self.__handler)
if self.__restore_level is not None:
# Restore level
self.__logger.setLevel(self.__restore_level)
if self.__path:
# Close our file pointer
self.__buffer_ptr.close()
self.__handler.close()
if self.__delete:
with contextlib.suppress(OSError):
# Always remove file afterwards
os.unlink(self.__path)
return exc_type is NoneMarvin
04/20/2026, 4:00 PMMarvin
04/20/2026, 4:01 PM# my_logging_filters.py
import logging
class DropAppriseInfo(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# Drop INFO (and below) from any 'apprise' logger
return not (record.name.startswith("apprise") and record.levelno < logging.WARNING)
2) Attach it to the handlers that emit your logs (console/api). Also set disable_existing_loggers: true so any pre-attached third‑party handlers don’t leak through.
version: 1
disable_existing_loggers: true
formatters:
json:
(): prefect.logging.formatters.JsonFormatter
fmt: default
dmft: null
style: '%'
filters:
drop_apprise_info:
(): my_logging_filters.DropAppriseInfo
handlers:
console_json:
class: prefect.logging.handlers.PrefectConsoleHandler
level: DEBUG
formatter: json
filters: [drop_apprise_info]
stream: <ext://sys.stderr>
api:
class: prefect.logging.handlers.APILogHandler
level: DEBUG
formatter: json
filters: [drop_apprise_info]
loggers:
# Optional: keep this minimal; handler-level filter already does the work
apprise:
level: INFO # can be NOTSET/INFO; filter will still drop < WARNING
handlers: [] # let it propagate
propagate: true
root:
level: INFO
handlers: [console_json, api]
Why this works
- Even if Apprise changes its logger level or attaches temporary handlers, anything that reaches your Prefect handlers gets filtered.
- disable_existing_loggers: true prevents previously-attached third‑party handlers from continuing to emit.
If you still see apprise INFO after this, double-check:
- PREFECT_LOGGING_SETTINGS_PATH is set in the worker’s runtime env
- my_logging_filters.py is importable in that env
- The specific handler that outputs what you’re seeing (console_json, api, etc.) has the filter attached
Optional verification
- Log the effective level/handlers at runtime:
import logging
l = logging.getLogger("apprise")
print("apprise level:", l.getEffectiveLevel(), "handlers:", l.handlers, "propagate:", l.propagate)Kalyan Yalamanchi
06/08/2026, 4:27 PMMarvin
06/08/2026, 4:27 PMMarvin
06/08/2026, 4:30 PMState object differently depending on where it came from.
If you do something like:
python
@flow
def inner():
...
return Failed(...)
@flow
def outer():
state = inner(return_state=True)
return state
the state returned from inner() is not just a plain value — it’s a Prefect-managed run state with flow run metadata attached. Returning that state from outer() can trigger Prefect’s state aggregation/orchestration logic instead of simply “propagating” the failure upward.
That’s why you can end up with:
text
Failed("Flow run encountered an exception: UnfinishedRun: Run is in SCHEDULED state, its result is not available.")
In short:
- inner(return_state=True) gives you a State for the inner flow run
- returning that same state from outer() is not the same as raising an exception
- Prefect may try to resolve/result-handle that state
- if that state is not actually final/resolved the way the engine expects, you can get UnfinishedRun: Run is in SCHEDULED state
Recommended patterns
1. Best option: let failures raise naturally
Instead of returning Failed, raise an exception in the inner flow and let Prefect mark the flow failed.
python
from prefect import flow
@flow
def inner():
raise ValueError("inner failed")
@flow
def outer():
inner()
That is the cleanest way to propagate failure.
2. If you inspect the inner flow state, don’t return that state object directly
python
from prefect import flow
@flow
def inner():
raise ValueError("inner failed")
@flow
def outer():
state = inner(return_state=True)
if state.is_failed():
# convert to your own exception / logic
raise RuntimeError(f"Inner flow failed: {state.message}")
return state.result()
3. If you truly want to return a final state from the outer flow, create a fresh manual state
Do not return the inner run’s state object directly. Return a new state instead.
python
from prefect import flow
from prefect.states import Failed
@flow
def inner():
raise ValueError("inner failed")
@flow
def outer():
state = inner(return_state=True)
if state.is_failed():
return Failed(message=f"Inner flow failed: {state.message}")
return state.result()
Important distinction:
- return state_from_inner_flow -> problematic
- return Failed(message="...") -> okay, because it’s a newly created state
Why this happens
Prefect docs/code distinguish between:
- manually created states you return yourself
- orchestrated states that belong to a task/flow run
A state produced by a nested flow run carries run identifiers, so returning it from another flow is not a safe way to “forward” failure.
What I’d suggest for your case
If your goal is “if inner fails, outer should fail too”, use this:
python
from prefect import flow
@flow
def inner():
raise ValueError("inner failed")
@flow
def outer():
state = inner(return_state=True)
if state.is_failed():
raise RuntimeError(state.message or "Inner flow failed")
return state.result()
If you want, paste your inner / outer flow code and I can rewrite it into the safest Prefect 3.x pattern.
Relevant docs:
- Flows
- StatesKalyan Yalamanchi
06/08/2026, 4:32 PMMarvin
06/08/2026, 4:32 PMMarvin
06/08/2026, 4:35 PMretries=...
- but flows do not support conditional retry logic like retry_condition_fn
- tasks do support retry_condition_fn
So if your inner flow has retries configured, then raising an exception from that flow will use the flow’s retry policy. That’s expected.
Best way to skip retries for certain errors
Move the retry policy out of the inner flow and onto a task wrapper that calls the inner flow. Then use retry_condition_fn on the task.
Example:
python
from prefect import flow, task
import httpx
class NonRetryableError(Exception):
pass
@flow
def inner_flow(x: int):
if x == 0:
raise NonRetryableError("This will never succeed")
if x < 0:
raise ValueError("Temporary/transient example")
return x * 2
def should_retry(task, task_run, state) -> bool:
try:
state.result()
except NonRetryableError:
return False
except Exception:
return True
return False
@task(retries=3, retry_delay_seconds=10, retry_condition_fn=should_retry)
def run_inner_flow(x: int):
return inner_flow(x)
@flow
def outer_flow(x: int):
return run_inner_flow(x)
Behavior:
- NonRetryableError -> fails immediately, no retries
- other exceptions -> retries happen
- success -> result returned normally
Why not return Failed(...)?
Returning Failed from the inner flow to suppress retries is not a great fit here:
- it complicates nested flow state handling
- it caused the UnfinishedRun behavior you saw
- it’s harder to reason about than normal exception handling
Alternative if you must keep retries on the inner flow itself
If the retries are declared directly on @flow(..., retries=...), there isn’t a built-in way to say “retry only for some exception types” at the flow level.
So your options are basically:
1. Recommended: remove retries from the inner flow and wrap the inner flow call in a task with retry_condition_fn
2. implement retry logic manually in Python
3. split “retryable” and “non-retryable” paths into different flows/tasks
Practical recommendation
If the inner flow is mostly orchestration and the risky work is happening inside tasks, an even cleaner design is:
- keep inner_flow with no flow retries
- put retries on the specific tasks that do network/API/database work
- use retry_condition_fn there
That usually gives the most precise control.
Docs:
- Retries guide
- Task API
- Flow API
If you want, I can help refactor your current inner flow pattern into one that:
- skips retries for specific exceptions
- still marks the outer flow failed
- avoids the nested Failed state issue entirelyKalyan Yalamanchi
06/08/2026, 5:08 PMMarvin
06/08/2026, 5:08 PMMarvin
06/08/2026, 5:09 PMsuccesses and failures
That pattern fits your use case much better than trying to pass Failed states around.
Recommended pattern
- The Prefect task runs the job
- The job builds its internal list
- The job executes all items
- It collects:
- succeeded items
- failed items
- non-retryable failures
- retryable failures
- If retryable failures remain, retry only those items inside the same task invocation, or surface a retryable exception so Prefect retries the task
- On the next attempt, pass forward only the remaining retryable failures
There are two ways to do this.
Option A: Handle all retries inside one task
This is simplest if you want full control.
python
from prefect import flow, task
import time
class RetryableItemError(Exception):
pass
class NonRetryableItemError(Exception):
pass
def build_items(job_config):
# your job file logic
return ["a", "b", "c"]
def process_item(item):
if item == "b":
raise RetryableItemError("temporary problem")
if item == "c":
raise NonRetryableItemError("bad input")
return f"processed-{item}"
@task
def run_job(job_config, items_to_process=None, max_internal_retries=3):
items = items_to_process or build_items(job_config)
successes = []
retryable_failures = []
non_retryable_failures = []
remaining = items
attempt = 0
while remaining and attempt < max_internal_retries:
attempt += 1
next_remaining = []
for item in remaining:
try:
result = process_item(item)
successes.append({"item": item, "result": result})
except NonRetryableItemError as exc:
non_retryable_failures.append({"item": item, "error": str(exc)})
except RetryableItemError as exc:
next_remaining.append(item)
remaining = next_remaining
if remaining:
time.sleep(5)
for item in remaining:
retryable_failures.append(
{"item": item, "error": "exhausted retries"}
)
return {
"successes": successes,
"retryable_failures": retryable_failures,
"non_retryable_failures": non_retryable_failures,
}
@flow
def outer_flow(job_config):
result = run_job(job_config)
if result["retryable_failures"] or result["non_retryable_failures"]:
# decide whether the flow should fail or complete-with-report
return result
return result
This gives you a full report and only retries failed items internally.
Option B: Use Prefect task retries, but keep track of only failed items
This is better if you want Prefect’s retry visibility.
```python
from prefect import flow, task
class RetryableItemsRemain(Exception):
def __init__(self, payload):
self.payload = payload
super().__init__("Retryable items remain")
class NonRetryableItemError(Exception):
pass
def build_items(job_config):
return ["a", "b", "c"]
def process_item(item):
if item == "b":
raise Exception("temporary issue")
if item == "c":
raise NonRetryableItemError("bad item")
return f"processed-{item}"
@task(retries=3, retry_delay_seconds=10)
def run_job_attempt(job_config, remaining_items=None, prior_successes=None, prior_non_retryable=None):
items = remaining_items or build_items(job_config)
successes = prior_successes or []
non_retryable = prior_non_retryable or []
retryable_remaining = []
for item in items:
try:
result = process_item(item)
successes.append({"item": item, "result": result})
except NonRetryableItemError as exc:Marvin
06/08/2026, 5:09 PMBut there’s an important limitation:
*Prefect task retries rerun the whole task call with the same inputs unless you explicitly persist and pass updated state yourself.*
So if you want attempt N+1 to process only the remaining failed items, you need some way to persist that intermediate payload between attempts. That usually makes *Option A* easier.
*My recommendation for your case*
Given:
- the work list is only known inside the job
- you want partial success reporting
- you want to retry only failed items
I’d use *manual retry inside a single Prefect task*.
That means:
- no nested flow-state tricks
- no fighting flow retry semantics
- easy to distinguish retryable vs non-retryable item failures
- easy to return a structured report
A good return shape would be:
```python
{
"successes": [...],
"retryable_failures": [...],
"non_retryable_failures": [...],
"total": 10,
"succeeded_count": 7,
"failed_count": 3
}
Then your outer flow can decide:
- fail the flow if any failures remain
- or complete the flow and publish the report
Example outer flow behavior:
python
from prefect import flow
@flow
def outer_flow(job_config):
result = run_job(job_config)
if result["retryable_failures"] or result["non_retryable_failures"]:
raise RuntimeError(
f"Job completed with failures: "
f"{len(result['retryable_failures'])} retryable, "
f"{len(result['non_retryable_failures'])} non-retryable"
)
return result
That way:
- you still get the detailed result object in logs / persisted results
- the flow ends in failed state if that’s what you want
Architecture suggestion
A clean split is:
- build_items(job_config) -> internal job logic
- process_item(item) -> single unit of work
- run_job(...) -> loop/retry/collect results
- outer_flow(...) -> orchestration and final pass/fail decision
If you want, I can sketch a concrete implementation for your exact case, for example:
- some failures should never retry
- some should retry up to N times
- return final summary
- fail the flow only if failures remainKalyan Yalamanchi
06/08/2026, 8:45 PM[
{
"schedule": {
"cron": "45 16 * * *",
"timezone": "America/New_York",
"day_or": true
},
"active": true,
"max_active_runs": null,
"catchup": true,
"slug": "test_scheduler_via_api_2",
"parameters": {
"job_keys": [
"staging_job_1"
]
}
}
]Marvin
06/08/2026, 8:45 PMMarvin
06/08/2026, 8:47 PMparameters are part of the deployment schedule schema and are expected to be returned on create/read.
So if the schedule is created but parameters are silently dropped, that points to one of these:
- you’re on an older Prefect/server version where schedule parameters were not fully wired through
- there’s a server/UI bug in the version you’re using
- the endpoint is accepting the payload shape but not persisting that field
Your request shape looks correct for `POST /deployments/{deployment_id}/schedules`:
json
[
{
"schedule": {
"cron": "45 16 * * *",
"timezone": "America/New_York",
"day_or": true
},
"active": true,
"max_active_runs": null,
"catchup": true,
"slug": "test_scheduler_via_api_2",
"parameters": {
"job_keys": [
"staging_job_1"
]
}
}
]
A few important notes:
- Per-schedule parameters are supported
- the create response should include them
- if they’re missing from both the created object and later reads, that’s not expected behavior
What to check next
1. Confirm your Prefect version
Both client and server matter here.
- prefect version
- if self-hosted, confirm the API/server version too
2. Read the schedule back directly after creation
Check whether the field is only missing from the create response, or truly not persisted.
3. Try updating an existing schedule with parameters
If PATCH also drops them, that strongly suggests a version/bug issue rather than request formatting.
4. Test against deployment defaults as a fallback
If schedule-level parameters are broken in your environment, deployment-level parameters may be a temporary workaround.
Likely conclusion
Based on the schema/API behavior, what you’re seeing does not look like intended behavior. It looks like a bug or version mismatch.
Relevant docs:
- Create schedules
- Manage schedules
- Schedules
- Create deployment schedules API
If you want, send:
- your prefect version
- whether you’re using Cloud or self-hosted server
- the exact endpoint you’re calling
and I can help narrow down whether this is a known version gap vs a likely bug.Kalyan Yalamanchi
06/08/2026, 8:49 PMMarvin
06/08/2026, 8:49 PMMarvin
06/08/2026, 8:49 PMRead Deployment Schedules
- the UI
- possibly the create response too
So this looks less like “parameters are dropped” and more like a serialization / API response / UI display bug.
Interpretation
If the actual scheduled run used job_keys=["staging_job_1"], then the backend is likely honoring the schedule override at scheduling time. That means your request body is probably fine.
What’s wrong is likely one of:
- the schedule read endpoint is omitting parameters
- the UI is not rendering parameters
- the create response schema/serialization is incomplete in your version
Practical takeaway
If the run is using the right parameters, then schedule-level parameters are probably functioning correctly despite the read/UI bug.
So for now:
- you can likely continue using the API request you already have
- but you should not rely on the UI or read-schedules response to confirm parameter values
- instead, verify via the created flow runs / flow run parameters
Best workaround right now
Validate by inspecting the resulting scheduled flow runs rather than the schedule object display.
This does sound like a bug
Because in Prefect 3.x, schedule objects are expected to include parameters, and the UI/read endpoint should reflect them.
Since I couldn’t reliably pull GitHub issue/discussion matches from the tooling right now, I can’t confidently point you to an existing issue. But based on behavior, this sounds worth reporting publicly if you don’t already see one.
You can use:
- GitHub issues
- GitHub discussions
If you report it, a concise repro would be:
text
Creating a deployment schedule with `parameters` via POST /deployments/{deployment_id}/schedules succeeds.
Scheduled runs correctly use the provided parameters.
However, the `parameters` field is missing from:
- the 201 create response
- the Read Deployment Schedules API response
- the Prefect UI
Useful details to include:
- Prefect client version
- server/cloud version
- whether Cloud or self-hosted
- exact request payload
- evidence that resulting flow runs received the expected parameters
If you want, I can help you write a minimal reproducible bug report for GitHub.Kalyan Yalamanchi
06/08/2026, 8:51 PMMarvin
06/08/2026, 8:52 PMMarvin
06/08/2026, 8:52 PMRead Deployment Schedules response
- possibly not shown in the create response either
That lines up well with your observation that:
- API write works
- scheduler behavior works
- UI write is broken
- UI/API readback is broken
So I’d treat issue #18701 as likely related, even if not a perfect duplicate.
What this means operationally
For now, the safest pattern is:
- continue setting schedule parameters via API
- verify correctness from the resulting flow run parameters
- don’t trust the UI or schedule read endpoint as the source of truth for this field right now
Suggested workaround
If you need confidence/auditability, store your intended schedule config outside the UI as well, for example:
- in code
- in your deployment config source
- or in a separate metadata artifact/log
That way you’re not depending on the broken read/display path.
What to watch for
If #18701 is indeed the same root cause, likely fixes would need to address one or more of:
- UI schedule parameter form serialization
- API response serialization for deployment schedules
- schema mismatches between create/read/update schedule payloads
If you want to comment on that issue, a helpful note would be something like:
text
I’m seeing related behavior:
- Setting schedule parameters in the UI fails
- Setting schedule parameters via API succeeds
- Scheduled runs do receive the expected parameters
- However, the parameters are not visible in the UI or in the Read Deployment Schedules API response
So the write path via API appears to work, but the read/display path seems broken.
Issue link:
- Prefect issue #18701
If you want, I can help you draft a tighter GitHub comment that distinguishes:
- UI save failure
- API success
- missing readback/display
so it’s easier for the team to reproduce.Kalyan Yalamanchi
06/11/2026, 2:50 PMMarvin
06/11/2026, 2:50 PMMarvin
06/11/2026, 2:52 PMprefect.flow-run.Failed and filters on the related tag resource.
If your deployments are tagged staging, the simplest JSON is:
json
{
"name": "Staging deployment failures",
"description": "Trigger when flow runs associated with the staging tag fail",
"enabled": true,
"trigger": {
"type": "event",
"posture": "Reactive",
"expect": [
"prefect.flow-run.Failed"
],
"match_related": {
"prefect.resource.id": [
"prefect.tag.staging"
],
"prefect.resource.role": "tag"
},
"threshold": 1
},
"actions": [
{
"type": "send-notification"
}
]
}
Important caveat
Prefect events expose tags as related prefect.tag.<tag> resources, but those tags are effectively combined from flow run / flow / deployment context.
So this will match failed flow runs that are associated with the staging tag, but it may not strictly mean “the deployment itself had the tag and nothing else did”.
If your use case is “alert on runs from deployments I conceptually mark as staging”, this is usually fine.
If you want to filter by a specific deployment too
You can combine filters using a list in `match_related`:
json
{
"name": "Specific staging deployment failures",
"enabled": true,
"trigger": {
"type": "event",
"posture": "Reactive",
"expect": [
"prefect.flow-run.Failed"
],
"match_related": [
{
"prefect.resource.id": [
"prefect.tag.staging"
],
"prefect.resource.role": "tag"
},
{
"prefect.resource.role": "deployment",
"prefect.resource.name": [
"my-flow/my-deployment"
]
}
],
"threshold": 1
},
"actions": [
{
"type": "send-notification"
}
]
}
Useful event details
For a failed flow run, the event name is:
- prefect.flow-run.Failed
Related resources can include:
- deployment
- flow
- work queue
- tags like prefect.tag.staging
If you’re creating this through the API, this JSON is for the automation payload body.
Docs:
- Automations
- Events
If you want, I can also give you:
- a full example with a Slack/webhook/email action, or
- the exact curl body for POST /automations