<@ULVA73B9P> Currently, we have a self-hosted Pref...
# ask-marvin
k
@Marvin Currently, we have a self-hosted Prefect server and our deployments pull a Github repository, install Python dependencies defined in that repo, and then run specified Python scripts in that repo via prefect-shell. What's the recommended way to do this?
m
thought for 237.0 seconds
Great question. In Prefect 3.x, the recommended pattern is: - Retrieve your code from Git at runtime using
flow.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
Copy code
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
Copy code
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)
Copy code
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 containers
- Kubernetes: Run flows on Kubernetes - prefect-shell: prefect-shell integration Quick clarification to tailor this: - Which work pool are you using today (Process, Docker, Kubernetes)? - Are your GitHub repos private, and if so, how are you authenticating today? I can provide a drop-in example matching your exact pool and auth setup.
k
@Marvin That is very similar to what we are currently doing. However I am noticing that most logs are not formatted as JSON despite having a logging configuration to do so. The configuration below is both deployed to the Prefect server as well as in the target Github repo where the scripts are located:
Copy code
# 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:
Copy code
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.
m
thought for 165.0 seconds
Thanks for the detail — there are two separate things happening here: 1) Your JSON logging config isn’t being applied where the flow actually runs 2) The script you launch via prefect-shell is a separate Python process with its own logging configuration and defaults (so INFO won’t emit unless that process sets its level) How to get JSON everywhere A) Make sure Prefect loads your logging.yml in the flow-run process - Prefect 3.x reads a logging config from: - PREFECT_LOGGING_SETTINGS_PATH (preferred), or - $PREFECT_HOME/logging.yml (default fallback) - Putting the file on the Prefect server or inside the Git repo won’t apply to flow runs, because logging is configured at interpreter startup, before pull steps (git clone, pip install) run. Do this instead: - Process work pool: - Put the file at ~/.prefect/logging.yml on the machine running the worker or set PREFECT_LOGGING_SETTINGS_PATH to an absolute path before starting the worker. - Example:
Copy code
export 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:
Copy code
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:
Copy code
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:
Copy code
# 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")
``` Note:
shell_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.
k
@Marvin I am using ShellOperation from prefect_shell, is shell_run_command better than this or the same?
m
thought for 21.1 seconds
Short answer: neither is strictly “better” — they target different use cases. - Use shell_run_command when: - You’re running a single command - You want the cleanest log lines (no “PID … stderr:” prefix) - You want to set the log level for streamed output (via stream_level) - You don’t need background/long-running process control - Use ShellOperation when: - You need to run multiple commands in sequence with a shared working_dir/env - You want background execution (trigger/atrigger) and to wait/fetch results later - You need to stream both stdout and stderr for long-running jobs - You want a single place to collect all output lines at the end Why your logs looked noisy - ShellOperation intentionally prefixes lines (e.g., “PID … stderr: …”) when stream_output=True. Those messages will still be JSON once your Prefect logging config is applied, but the message body includes that prefix. - shell_run_command logs each stdout line directly through the task logger, so the messages are “cleaner” in JSON. Examples - Cleaner single-command with shell_run_command
Copy code
from 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
Copy code
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 integration
k
@Marvin Can shell_run_command be used synchronously?
m
thought for 36.0 seconds
Yes. In Prefect 3.x, shell_run_command supports both sync and async use. - In a synchronous flow: call it directly (no await) - In an async flow: await it - Outside of a flow: you can call it like a normal function; it will run the command and return the result, but you won’t get orchestration features (e.g., task-run context, retries) unless it’s called from within a flow Examples - Sync flow
Copy code
from 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
Copy code
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 integration
k
@Marvin I am getting the following error:
Copy code
TypeError: JsonFormatter.__init__() is missing 2 required positional arguments: 'dmft' and 'style'
m
thought for 71.6 seconds
You’re hitting a quirk in Prefect’s JsonFormatter constructor: it currently requires three parameters and the second is misspelled as dmft (should’ve been datefmt). DictConfig will try to instantiate it and fails if you only pass fmt. Fix: - Use the custom factory key '()' for the formatter - Provide all three expected kwargs: fmt, dmft, style - Remove any format keys (those are for logging.Formatter, not JsonFormatter) Working snippet
Copy code
version: 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.
k
@Marvin Switching to shell_run_command caused logs from the internal shell process to no longer appear, reverting to ShellOperation restored them. Why is that?
m
thought for 22.3 seconds
Short answer: shell_run_command streams only stdout; ShellOperation streams both stdout and stderr. Your script’s Python logging by default writes WARNING+ to stderr, so with shell_run_command those “internal shell process” logs disappear unless the command fails. ShellOperation shows them because it explicitly reads and re-logs stderr (with the PID prefix). Ways to fix if you want to keep shell_run_command - Merge stderr into stdout in the command: - POSIX/bash:
Copy code
await shell_run_command("python -u scripts/my_job.py 2>&1")
- With an explicit shell:
Copy code
await shell_run_command(
      "python -u scripts/my_job.py 2>&1",
      shell="bash"
    )
- PowerShell:
Copy code
await shell_run_command("python -u scripts/my_job.py 2>&1", shell="powershell")
- Make the script log to stdout instead of stderr:
Copy code
# 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 integration
k
@Marvin The logs on the internal command are now coming through correctly as JSON. There's still a remaining issue now with Task Run and Flow Run logs not coming through as JSON. Samples below:
Copy code
20: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'
m
thought for 42.9 seconds
Thanks — two likely causes explain why Flow/Task logs are still not JSON while your shell command logs are: 1) You’re still picking up Prefect’s default logger config for flow/task loggers - By default, Prefect attaches its own console handler (with the human-readable PrefectFormatter) to the
prefect
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.
Copy code
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.
k
@Marvin I am still not seeing logs coming from shell_run_command but see them from ShellOperation. Here is the logging.yml that is used for both the prefect server as well as loaded into the script that is being run via shell:
Copy code
# 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]
m
thought for 231.4 seconds
Thanks for sharing the config — that helps. The short answer: in Prefect 3.x,
shell_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:
Copy code
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:
Copy code
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:
Copy code
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:
Copy code
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.
k
@Marvin I am expecting to see the logs in stdout or stderr where the other logs appear. I do not want to log to the Prefect UI. Ignore prefect UI logging going forward. prefect version = 3.6.25 prefect-shell version = 0.3.5 Code for calling shell_run_command:
Copy code
@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:
Copy code
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__)
m
thought for 68.6 seconds
Got it — we’ll focus only on stdout/stderr. Two key things to fix based on your snippet and goals: 1) You’re not awaiting shell_run_command - In Prefect 3.x,
shell_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:
Copy code
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):
Copy code
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:
Copy code
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:
Copy code
await shell_run_command("echo hello", shell="bash", stream_level=logging.DEBUG)
and set your console handler level to DEBUG to verify.
k
@Marvin in a previous message: https://prefect-community.slack.com/archives/C04DZJC94DC/p1775503784004539?thread_ts=1775491757.407839&amp;cid=C04DZJC94DC you claimed shell_run_command can run synchronously in Prefect 3.x. Is this not the case?
m
thought for 109.1 seconds
Great question — thanks for calling that out. Clarification: - shell_run_command is implemented as an async function under the hood, but it’s wrapped with Prefect’s @task. That means: - You can call it from a synchronous flow without await (i.e., it “works synchronously” from the caller’s perspective). - In an async flow, you can await it. - If you call it from inside another task, it executes inline within that task (it does not create a separate child task run). So the previous statement that it can run “synchronously” was about call ergonomics in sync flows — not that the underlying function is purely synchronous. Examples: - Sync flow (no await needed):
Copy code
from prefect import flow
from prefect_shell import shell_run_command

@flow
def f():
    return shell_run_command("echo hello")
- Async flow:
Copy code
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:
Copy code
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.
k
@Marvin I've fixed up the logging to a good enough place, switching topics. I have the following code with an error hook:
Copy code
def 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.
m
thought for 187.4 seconds
You’re running into a state/hook timing issue. Key point: on_failure hooks only run when the task run actually enters a Failed terminal state. If the exception is intercepted before Prefect finalizes the task run as Failed (e.g., during retry scheduling or because the flow catches/short-circuits the task error), the hook won’t fire. Why you’re seeing this behavior - ModuleNotFound case: likely reached a final Failed state (no retries left), so the hook ran. - “Raise inside the script” case: your flow is wrapping the task call in a try/except. That can short-circuit Prefect’s normal lifecycle (e.g., the first attempt goes to AwaitingRetry, not Failed, and your try/except may swallow the engine’s retry/exception signal), so the task never lands in a Failed terminal state and the hook doesn’t run. Also, ShellOperation.run() raises a RuntimeError on non-zero exit code. If the script ever catches its own exception and exits 0, Prefect will treat it as success; no hook will run. How to ensure the hook triggers and still let your flow continue - Best pattern: submit tasks and let Prefect manage their state transitions (hooks will run), then handle results/errors afterwards so your flow keeps going.
Copy code
from 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:
Copy code
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:
Copy code
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.
k
@Marvin that previous code was old and can be ignored. The following is the updated code with the issue:
Copy code
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}")


@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.
m
thought for 61.8 seconds
Thanks for the updated snippet — a few important details explain what you’re seeing. Why the hook sometimes doesn’t fire - Your hook is attached to extract_and_load_task which is a flow. Flow-level on_failure hooks only run when that flow run reaches a terminal Failed state. - You’ve set retries=2 on that flow. On the first failed attempt, the flow does not become Failed — it becomes AwaitingRetry. No on_failure hook runs yet. Depending on the error, you may see an exception surface to the caller that you catch in chunk_subflow, but because the subflow is not in a Failed state, the hook won’t run. - Only when the subflow has exhausted its retries and ends in Failed will the on_failure hook run. If you catch the final failure in the parent, the hook will already have been invoked by the subflow engine. Why it can appear inconsistent by error type - If the inner script exits non-zero consistently and the subflow’s retries are hit, you’ll only get the hook on the last (terminal) failure. - If the inner script swallows its exception and exits 0, the subflow is successful and no hook runs. - If an error is raised before the retry machinery kicks in (rare edge paths), you may see a terminal failure immediately and the hook will run once. How to make this deterministic and keep sequential processing Option A: Attach the hook to the task that actually fails (recommended) - Bind your on_failure hook directly to shell_run_command. Then it will fire when the task fails (after its own retries), regardless of the parent flow’s try/except. Example:
Copy code
from 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:
Copy code
@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:
Copy code
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.
- You stated no concurrency and no Prefect UI logging — both options above keep sequential execution and only write to your configured stdout/stderr handlers. If you prefer to keep flow-level retries and a flow-level on_failure hook, you’ll only get the Slack message when retries are exhausted (terminal Failed). Catching exceptions in chunk_subflow is fine; it will not prevent the hook from firing on that terminal failure — but it will often catch earlier retry/awaiting states where the hook hasn’t run yet.
k
@Marvin Updated the code, the hook triggered however the Slack webhook block had an issue:
Copy code
"'coroutine' object has no attribute 'raise_on_failure'"
Seems like the Slack webhook block is now async or something?
m
thought for 117.0 seconds
That error means your variable slack_webhook_block is actually a coroutine, not a block instance. That typically happens if either: - You called SlackWebhook.aload(...) and didn’t await it, or - You’re importing a different SlackWebhook than intended and its load returns a coroutine. What to check and fix - Import the core block and use the sync loader in a sync hook:
Copy code
from 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:
Copy code
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:
Copy code
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.
k
@Marvin
Copy code
from prefect.blocks.notifications import SlackWebhook
Copy code
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}")
m
thought for 49.3 seconds
Thanks for the details — with that import, the SlackWebhook block itself is fine. Two issues are likely causing the “‘coroutine’ object has no attribute ‘raise_on_failure’” and a separate small bug: 1) Make sure you are not shadowing SlackWebhook.load with aload - With core blocks, SlackWebhook.load(...) is synchronous and returns an instance. If you accidentally used SlackWebhook.aload(...) (or wrapped .load in an async function and forgot to await), you’ll get a coroutine and then .raise_on_failure will fail. - In your snippet you are using .load — good. If this code runs inside an async hook, it’s still okay; .load is sync. If you switch to .aload, remember to await it. 2) flow_run.get_flow_run_ui_url() is not the right accessor in 3.x - In Prefect 3.x, use prefect.runtime.flow_run.ui_url. It’s an attribute, not a function call. Putting it together, here’s a corrected hook you can drop in as-is for sync execution and stdout/stderr logging only:
Copy code
from 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:
Copy code
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:
Copy code
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.
k
@Marvin the type(slack_webhook_block) returns <class 'coroutine'>
m
thought for 219.4 seconds
Thanks — that confirms your hook is running in an async context. In Prefect 3.x, many block methods (including Block.load and SlackWebhook.notify) use async-dispatch. In an async context they return a coroutine unless you either await them or force the sync path. Two easy ways to fix it: Option A: Keep your hook sync and force sync behavior - Pass _sync=True to both load and notify so they run synchronously even in an async context.
Copy code
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

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:
Copy code
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.
k
@Marvin I tried Option A. The type is now <class 'prefect.blocks.notifications.SlackWebhook'> however the Slack webhook never actually sent. Additionally, there was the following warning log which seems to indicate that the _sync used in notify() did not work:
Copy code
/user/local/lib/python3.12/site-packages/prefect/blocks/notifications.py:129: RuntimeWarning: coroutine 'AbstractAppriseNotificationBlock.anotify' was never awaited
m
thought for 96.3 seconds
Thanks for testing that. The warning explains exactly what’s happening: - Your call to notify(..., _sync=True) correctly selected the sync body in AppriseNotificationBlock.notify. - Inside that sync body, Prefect calls super().notify(body, subject) without forwarding _sync=True. - The parent class’s notify is also async-dispatched; since you’re in an async context, it routed to anotify and returned a coroutine that wasn’t awaited → “coroutine … was never awaited” and nothing was sent. So Option A can fail in async contexts due to this double-decorator chain not propagating _sync. Best fix: make the hook async and await This avoids the dispatch confusion entirely.
Copy code
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")

    # 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.
k
@Marvin is there a way to make the hook async without having to convert all the flow and task functions in the chain of calls to async as well?
m
thought for 18.4 seconds
Yes — you can make just the hook async. Prefect will automatically await async hooks; you do NOT need to convert your flows or tasks to async. Here’s a drop-in async hook that fixes the Slack dispatch issue without changing your flows/tasks:
Copy code
from 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.
k
@Marvin for logging, I have noticed logs are "clumping" together where multiple log messages from the script run via shell show up in the same JSON message appended together with "\n"
m
thought for 138.0 seconds
Got it — this happens when running commands via prefect-shell because the subprocess stdout/stderr is read in chunks, not strictly line-by-line. Prefect logs one record per chunk, so if the chunk contains multiple lines, they appear “clumped” into a single JSON log entry with embedded “\n”. Why it happens -
shell_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:
Copy code
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:
Copy code
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.py
k
@Marvin prefect version is 3.6.25, prefect-shell version is 0.3.5. I am using shell_run_command currently.
m
thought for 97.4 seconds
Thanks for the details. With Prefect 3.6.25 and prefect-shell 0.3.5,
shell_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.
Copy code
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.
Copy code
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.
Copy code
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.
k
@Marvin I need real time logging
m
thought for 45.0 seconds
Understood — for real-time, per-line logs, you’ve got two solid paths with Prefect 3.6.25 + prefect-shell 0.3.5: Option A: Use ShellOperation (sync) for true line-by-line streaming - The sync path reads stdout/stderr line-by-line and logs each line as it’s produced. - This is the most straightforward way to get real-time, per-line logs.
Copy code
from 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.
Copy code
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)?
k
@Marvin I can switch to Option A. What is the best way to refactor to also keep the Slack failure hook in the below sample code:
Copy code
async 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 results
m
thought for 115.4 seconds
Great — here’s a clean refactor that switches to ShellOperation (sync) for real-time, per-line logs while keeping your Slack failure hook. I also fixed a few nits (added get_run_logger, proper imports, flow_run.ui_url). Notes: - ShellOperation.run() streams line-by-line with stream_output=True and raises on non‑zero exit, so your on_failure hook will fire. - I set PYTHONUNBUFFERED to encourage timely line flushes from your Python scripts.
Copy code
from 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#slackwebhook
k
@Marvin If I want to instead just import and run the scripts directly, give me a sample prefect.yaml file to do the following: • Allow for pulling from different branches of a repo for different deployments in the same prefect.yaml • Pull using a GithubRepository (with an attached GithubCredentials in the block) • install Python dependencies via Poetry or a requirements.txt file in a way such that the dependencies are in place before loading the repo code which will have failed imports otherwise
m
thought for 252.8 seconds
Awesome — here’s a
prefect.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: |
set -euxo pipefail cd "{{ clone.directory }}" # Option A: Poetry export -> pip install (keeps packages in current interpreter) poetry export --without-hashes -f requirements.txt -o /tmp/req.txt python -m pip install --upgrade pip python -m pip install -r /tmp/req.txt # Option B (not recommended for Prefect import model):
poetry 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-github
k
@Marvin how to define multiple different work pools to be used by different deployments?
m
thought for 37.1 seconds
In Prefect 3.x you assign a work pool per deployment in prefect.yaml. The simplest pattern is to define multiple pool configs (via YAML anchors) and reference the right one from each deployment. Here’s a focused example showing three different pools used by different deployments:
Copy code
---
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):
Copy code
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.
k
@Marvin I created a minimal prefect.yaml below:
Copy code
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."

# 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.
m
thought for 133.0 seconds
You’re hitting this because
prefect 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:
Copy code
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:
Copy code
prefect deploy
or
Copy code
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-steps
k
@Marvin With flow.from_source().deploy(), is there a way to run the "pip install" before loading the Python files in the repo? Currently we use this method and install the dependencies but then have to run the Python files via a shell command.
m
thought for 75.9 seconds
Short answer: not directly with flow.from_source(...).deploy(...). The Python API does not expose “pull steps,” so there isn’t a built-in hook to run pip install before Prefect imports your flow files at runtime. Ways to achieve “install before import” without shell-running your scripts: - Use prefect.yaml pull steps (recommended) - Workers run pull steps before importing your entrypoint, so you can clone + pip install first. - Minimal example:
Copy code
---
  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:
Copy code
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:
Copy code
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.
k
@Marvin Using the above sample prefect.yaml I used, I got an error saying "pull with block() got an unexpected keyword argument 'block'"
m
thought for 37.6 seconds
You’re seeing that error because the step’s signature is not
block=...
. 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
Copy code
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-github
k
@Marvin Using your above proposed changes, I now get an error saying "set_working_directory() missing 1 required positional argument: 'directory'"
m
thought for 100.3 seconds
Good catch — that error happens because the templating reference in set_working_directory didn’t have an id to bind to, so Prefect tried to call set_working_directory with no directory. Two fixes: 1) Give each step an id and reference the prior step’s output 2) Or skip set_working_directory entirely and just pass the directory to pip_install_requirements Working version with ids
Copy code
prefect-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.
Copy code
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-steps
k
@Marvin I am now loading in files from another repo and calling the scripts directly via imports instead of a shell command. Logs triggered from inside the flows show as JSON as expected. However some logs are still coming through as non-JSON. This is mainly the logs generated as part of the deployment indicating which steps of the deployment are running as well as the
pip install
output. Can these be formatted to JSON easily?
m
thought for 125.0 seconds
Short answer - Logs that go through Prefect’s logging system can be JSON-formatted. - Output from deployment “step runner” console messages and from subprocesses (like pip install) is printed directly to stdout/stderr and does not pass through Prefect’s log formatter, so it won’t be JSON without custom handling. What you can JSON-format easily - Deployment step logs emitted via the “deployment” logger can be switched to JSON by using a JSON formatter in your Prefect logging config. - Flow/task logs already show up as JSON in your setup. How to enable JSON for step logs - Create a custom logging config and set it via PREFECT_LOGGING_SETTINGS_PATH or by overriding specific keys via environment variables. Minimal logging.yml that forces JSON on the console and ensures the “deployment” logger uses it:
Copy code
formatters:
  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).
k
@Marvin I have a logging config and the variable
PREFECT_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.
m
thought for 95.7 seconds
You’re seeing both JSON and non-JSON because there are two different emit paths during a run: 1) Logs that go through Prefect’s logging system → your JSON formatter applies - Example: “prefect.flow_runs.worker” early runtime messages - Pull steps executed in a flow-run context (with a logger) will also go through logging 2) Console/stream output that bypasses logging → not JSON - deploy/pull/build step progress text that uses print_function (Rich console) - subprocess output from pip_install_requirements (streams to stdout/stderr) Why your deployment-step logs aren’t JSON - run_steps uses a logger only when it’s called with one (flow-run context). In other contexts, it falls back to console printing. - pip_install_requirements streams pip’s stdout/stderr directly to sys.stdout/sys.stderr (not via logging). What you can do now without patching Prefect - Ensure your pull steps run in the flow-run context (they typically do at worker runtime) so they use the flow-run logger: - Those logs can be JSON if your logging config attaches a JSON formatter to the flow-run loggers. - Keep in mind: pip output from pip_install_requirements will still be non-JSON because it’s not logged, it’s streamed. If you need fully JSON logs for step progress and pip output, you have two options: Option A: Turn off streaming and re-log via Prefect’s logger - Replace pip_install_requirements with a run_shell_script or a tiny custom step that: - runs pip with stream_output=False or captures stdout/stderr - emits each captured line via get_run_logger().info(...) so it’s JSON-formatted - Example custom step concept:
Copy code
- 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:
Copy code
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).
k
@Marvin The logging config has an entry for
prefect
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.
m
thought for 145.7 seconds
Thanks for the extra detail—that helps narrow this down. What’s going on - In Prefect 3.6.25, two different emit paths are in play during a run: 1) Logs that go through Prefect’s logging system (e.g., prefect.flow_runs.*) → your JSON formatter applies 2) Console/stream output that bypasses logging → not JSON - For pull steps defined in prefect.yaml: - The progress headers like “Running prefect.deployments.steps.pull_with_block…” can be emitted via a console print path in 3.6.25 depending on context, so they won’t pick up your JSON formatter. - pip_install_requirements streams pip’s stdout/stderr directly to sys.stdout/sys.stderr, completely bypassing Prefect’s logging. This output cannot be JSON-formatted by logging config. Why adding the “deployment” logger didn’t change those lines - Those specific lines are not coming from the “deployment” logger. The step progress messages in your version are printed (not logged), and pip output is raw subprocess output. Since they bypass logging, your JSON formatter on “prefect” or “deployment” won’t apply. What you can do Option A: Suppress the non-JSON pip noise (quickest) - Turn off streaming for pip so only your normal Prefect logs remain JSON:
Copy code
- 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):
Copy code
# 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:
Copy code
- 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.
k
@Marvin ignore the pip logs for now. Is there an easy way to get the step progress logs into JSON?
m
thought for 49.2 seconds
Short answer: not easily today. Why - During a flow run, some step progress does go through Prefect’s logging system and will respect your JSON formatter. - But the generic “step progress” output you’re seeing for prefect.yaml steps is emitted via a console print path in 3.6.25, not the logging system. Since it bypasses logging, your JSON formatter on the
prefect
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.
k
@Marvin switching topics. I notice a log coming through from
apprise
about sending a Slack notification:
Copy code
{
  "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:
Copy code
loggers:
  ...
  apprise:
      level: WARNING
      handlers: [json]
m
thought for 54.7 seconds
You’re right to expect that setting the apprise logger to WARNING should suppress that INFO line. The fact you still see it means one of these is happening: - Your logging.yml is not fully applied to that process/thread - The apprise log is propagating to the root logger, which still emits at INFO - The logger section isn’t being honored as written (e.g., missing version/disable_existing_loggers) or the handler name doesn’t exist A reliable way to silence apprise INFO is to attach a NullHandler and stop propagation. Here’s a minimal, self-contained snippet you can merge into your logging.yml that works even if apprise tries to propagate:
Copy code
version: 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.
k
@Marvin won't the above config route WARNING-level apprise logs to the NullHandler, hiding them?
m
thought for 36.3 seconds
You’re right — the NullHandler example I gave would swallow WARNING and above too. If you want to drop only INFO but keep WARNING+ visible, don’t send apprise to a NullHandler. Two clean ways to do it: A) Let warnings bubble to root (keep your JSON there)
Copy code
version: 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
Copy code
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.
k
@Marvin I've tried option B and it didn't work.
m
thought for 51.0 seconds
Good callout — if setting logger apprise to WARNING with propagate: false didn’t suppress the INFO line, the safest fix is to filter it at the handler level so those records never make it to your sinks, regardless of how apprise configures its logger. Do this in two steps: 1) Add a tiny custom filter in your codebase (make sure it’s importable in the worker’s environment)
Copy code
# 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.)
Copy code
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)
k
@Marvin Switching topics, when running jobs, task failure results in JSON logs with odd messages, like the following:
Copy code
Task run failed with %s: %r - Retry %s/%s will start %s
and an args property as follows:
Copy code
`["exception", {"__exc_type__":"builtins.AttributeError","message":"module 'data_loaders.google_sheets has no attribute'run
m
thought for 53.2 seconds
You’re seeing the raw format string because Python logging stores two things on each record: - msg: the unformatted template (e.g., "Task run failed with %s: %r - Retry %s/%s will start %s") - args: the parameters to be applied later The human-readable text is record.getMessage(), which your JSON formatter should put into a field like message. In other words: - msg and args are “inputs” - message is the final, formatted string What to check/fix - Verify your JSON shows a message field; if your sink/collector is displaying msg instead of message, switch it to message. - Ensure your handler is actually using Prefect’s JsonFormatter (it calls record.getMessage() under the hood). If you swapped in a custom formatter that serializes record.msg/args without record.getMessage(), you’ll see placeholders. If you want to hide msg/args entirely - Use a custom JSON formatter that only emits message (and whatever else you care about):
Copy code
# 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:
Copy code
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).
k
@Marvin My formatter in logging.yml is as follows:
Copy code
json:
    '()': 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
.
m
thought for 139.0 seconds
Thanks — two things jump out: 1) Your formatter kwargs have a typo: it should be “style”, not “stype” - Correct config:
Copy code
formatters:
  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):
Copy code
# 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:
Copy code
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.
k
@Marvin looks like I typed it wrong in my last message to you, the actual config used is below:
Copy code
json:
        '()': 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:
Copy code
{"name":"prefect.task_runs","msg":"Task run failed with exception: Exception('Kal Test Exception') - Retries are exhausted","args":[],"levelname":"ERROR",
...
}
m
thought for 207.0 seconds
k
@Marvin the other message:
Copy code
{"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",
...
}
m
thought for 128.6 seconds
Thanks for the concrete example. You’ve run into Prefect’s JsonFormatter design: it intentionally serializes the raw LogRecord dict and does not add a “message” field. That’s why you see: - msg: the unformatted template - args: the parameters - but no message: record.getMessage() Your formatter config is fine once you fix the earlier “style” typo. However, with Prefect’s JsonFormatter, “message” will still be absent by design. You have three viable paths: Option 1 — Switch to a formatter that includes record.getMessage() - Use a small wrapper that subclasses Prefect’s JsonFormatter and injects message:
Copy code
# 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:
Copy code
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:
Copy code
# 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:
Copy code
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.
Thanks for the concrete samples — that explains the behavior you’re seeing. What’s happening - Prefect’s built-in JsonFormatter intentionally serializes the LogRecord dict as-is (plus a few extras like severity) and does not call record.getMessage(). That means: - msg is the unformatted template - args holds the parameters - message is not emitted at all by default - That’s why your “retries exhausted” record looks readable (it was built with an f-string, so msg already contains the final text and args is empty), while the “retrying” record shows the raw template with %s/%r placeholders (it uses %-formatting with args). Your current config is correct
Copy code
formatters:
  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().
Copy code
# 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:
Copy code
# 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:
Copy code
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.
k
@Marvin The issue I'm trying to solve is the formatting of the message with placeholders. Using the
msg
key instead of
message
is fine.
m
thought for 36.9 seconds
Got it — since you’re okay using the msg field, the simplest way to ensure placeholders are resolved is to add a small logging Filter that formats the message before Prefect’s JsonFormatter runs. The filter will replace record.msg with record.getMessage() and clear record.args, so your JSON will always contain a fully formatted msg. 1) Create a tiny filter your worker can import
Copy code
# 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:
Copy code
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.py
k
@Marvin That fixed it. Going back to the issue with the "apprise" logger, here is the code for the logger that the "apprise" library has set up which is generating INFO logs that I wish to suppress:
Copy code
import 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 None
m
Your message was too long, here's your message at the allowed limit: That fixed it Going back to the issue with the apprise logger here is the code for the logger that the apprise library has set up which is generating INFO logs that I wish to suppress ```import 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
k
@Marvin That fixed it. Going back to the issue with the "apprise" logger, here is the code for the logger that the "apprise" library has set up which is generating INFO logs that I wish to suppress:
Copy code
...

# 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 None
m
Your message was too long, here's your message at the allowed limit: That fixed it Going back to the issue with the apprise logger here is the code for the logger that the apprise library has set up which is generating INFO logs that I wish to suppress ``` # 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
k
@Marvin That fixed it. Going back to the issue with the "apprise" logger, here is the code for the logger that the "apprise" library has set up which is generating INFO logs that I wish to suppress:
Copy code
...

# 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 None
m
thought for 38.9 seconds
Thanks for sharing Apprise’s logger code — that helps. What it means - Apprise logs on the logger named “apprise”. - It may temporarily attach its own handler via LogCapture, but your JSON sample shows the record is going through Prefect’s handler/formatter (not Apprise’s plain text Formatter), so we can filter it at your handlers. Most reliable fix - Add a handler-level filter that drops apprise logs below WARNING. This works regardless of how Apprise configures its logger. 1) Add a small filter your worker can import:
Copy code
# 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.
Copy code
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:
Copy code
import logging
l = logging.getLogger("apprise")
print("apprise level:", l.getEffectiveLevel(), "handlers:", l.handlers, "propagate:", l.propagate)
k
@Marvin I am returning a prefect.states.Failed from an inner flow to an outer flow and then also returning this Failed from the outer flow. The outerflow is the entrypoint but getting the error: "Finished in state Failed('Flow run encountered an exception: UnfinishedRun: Run is in SCHEDULED state, its result is not available.')
m
thought for 197.4 seconds
What’s happening is that Prefect treats a returned
State
object
differently depending on where it came from. If you do something like:
Copy code
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:
Copy code
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.
Copy code
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
Copy code
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.
Copy code
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:
Copy code
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 - States
k
@Marvin My goal was to avoid the retry functionality attached to the inner flor certain errors that will never succeed. Raising an Exception seems to always trigger the retry functionality.
m
thought for 159.6 seconds
Yes — that’s the key distinction. In Prefect 3.x: - flows support
retries=...
- 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:
Copy code
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 entirely
k
@Marvin I have a list of tasks to run inside a job that is called from a flow. The list is generated inside the job file which requires some specific info so it's difficult to generate the list at the flow/task level and generate multiple ones. I want to return a list of successes and failures and only retry the failures.
m
thought for 31.4 seconds
Got it — in that case, don’t model each item as a separate Prefect task/flow retry if the item list only exists inside the job logic. Instead, treat the whole job as: - one Prefect task - with manual partial retry logic inside the task - returning structured results like
successes
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.
Copy code
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:
non_retryable.append({"item": item, "error": str(exc)}) except Exception: retryable_remaining.append(item) if retryable_remaining: raise RetryableItemsRemain( { "remaining_items": retryable_remaining, "successes": successes, "non_retryable": non_retryable, } ) return { "successes": successes, "non_retryable_failures": non_retryable, "retryable_failures": [], } @flow def outer_flow(job_config): try: return run_job_attempt(job_config) except RetryableItemsRemain as exc: return { "successes": exc.payload["successes"], "non_retryable_failures": exc.payload["non_retryable"], "retryable_failures": exc.payload["remaining_items"], }
Copy code
But 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:
Copy code
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 remain
k
@Marvin im unable to set "parameters" on API requests to create a schedule (unable to do via UI either due to a bug). The following is the body of the request, the schedule is created but the "parameters" are just dropped and don't show up on the schedule or the 201 response body from the creation request.
Copy code
[
  {
    "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"
      ]
    }
  }
]
m
thought for 157.4 seconds
This should be supported in Prefect 3.x —
parameters
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`:
Copy code
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.
k
@Marvin it looks like the actual run did use the parameters, but they don't show up in the Read Deployment Schedules API response or in the UI.
m
thought for 15.5 seconds
That helps a lot — it suggests: - the schedule parameters are being stored/applied for execution - but they are not being surfaced correctly in: -
Read 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:
Copy code
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.
k
@Marvin https://github.com/PrefectHQ/prefect/issues/18701 it looks like this issue is referring to the same or similar problem which I also noticed. Setting the parameters in the UI causes it to fail (JSON mode or not) but setting via API does not have the same issue of these extra fields showing up although the UI and API both aren't displaying the parameters at all.
m
thought for 13.3 seconds
Yes — that sounds like the same family of bugs. From what you described, there may actually be two related issues: 1. UI save/validation bug Setting schedule parameters in the UI fails, likely due to bad form/schema handling or extra fields being injected. 2. Read/display bug Parameters set successfully via API are honored by scheduled runs, but: - not shown in the UI - not shown in the
Read 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:
Copy code
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.
k
@Marvin I have a set of deployments with the tag "staging" and I want to make an Automation that will trigger when runs of this deployment enter the state "Failed", how would I configure that via the Automation JSON?
m
thought for 132.5 seconds
You can do this with an event trigger automation that listens for
prefect.flow-run.Failed
and filters on the related
tag
resource. If your deployments are tagged
staging
, the simplest JSON is:
Copy code
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`:
Copy code
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