Mat Partridge
05/20/2026, 8:48 AMMarvin
05/20/2026, 8:48 AMMarvin
05/20/2026, 8:49 AM@ecs decorator, you pass them as job variables directly on the decorator.
Example:
python
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(
work_pool="my-ecs-pool",
vpc_id="vpc-12345678",
network_configuration={
"subnets": [
"subnet-12345678",
"subnet-87654321",
],
"securityGroups": [
"sg-12345678",
"sg-87654321",
],
"assignPublicIp": "ENABLED",
},
)
@flow
def my_flow():
pass
A few important notes:
- network_configuration is the key you want
- inside it, use:
- subnets
- securityGroups
- assignPublicIp
- if you're setting custom networking, include vpc_id as well
So the relevant part is:
python
@ecs(
work_pool="my-ecs-pool",
vpc_id="vpc-12345678",
network_configuration={
"subnets": ["subnet-1", "subnet-2"],
"securityGroups": ["sg-1", "sg-2"],
"assignPublicIp": "DISABLED",
},
)
If helpful, I can also show you the equivalent setup in:
- prefect.yaml
- flow.deploy(...)
- an ECS work pool's base job template
Docs:
- Prefect AWS ECS worker docs
- Prefect docsMat Partridge
05/20/2026, 9:28 AMMarvin
05/20/2026, 9:28 AMMarvin
05/20/2026, 9:31 AMnetwork_configuration.
What’s likely happening
In prefect-aws, some ECS job variable fields are defined with default_factory (including network_configuration). During parameter/schema generation, Prefect ends up with a field that effectively has both a default and a default_factory, and Pydantic rejects that with:
text
cannot specify both default and default_factory
So this is likely an issue in the interaction between the @ecs decorator / deployment parameter handling and the ECS worker variable schema — not that your subnet/security group values are malformed.
Most likely trigger
Using something like this on the decorator:
python
@ecs(
work_pool="my-ecs-pool",
vpc_id="vpc-12345678",
network_configuration={
"subnets": [...],
"securityGroups": [...],
"assignPublicIp": "ENABLED",
},
)
may be enough to trigger it.
Things to try right now
1. Use vpc_id only first
Remove network_configuration temporarily and see if the deployment/run succeeds:
python
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(
work_pool="my-ecs-pool",
vpc_id="vpc-12345678",
)
@flow
def my_flow():
pass
If that works, it strongly points to network_configuration as the culprit.
2. Move networking config out of the decorator and into the work pool / deployment job variables
Instead of putting it on the decorator, set it in prefect.yaml or the work pool configuration.
Example in `prefect.yaml`:
yaml
deployments:
- name: my-deployment
entrypoint: flows.py:my_flow
work_pool:
name: my-ecs-pool
job_variables:
vpc_id: vpc-12345678
network_configuration:
subnets:
- subnet-12345678
- subnet-87654321
securityGroups:
- sg-12345678
assignPublicIp: ENABLED
3. Make sure network_configuration is not also a flow parameter
For example, avoid:
python
@flow
def my_flow(network_configuration: dict = {}):
pass
Use:
python
@flow
def my_flow():
pass
About the RuntimeWarning
text
RuntimeWarning: 'prefect_aws.experimental.bundles.execute' found in sys.modules...
That looks secondary. It suggests some experimental bundles module is being imported in a way Python doesn't like, but it's probably not the root cause of the validation failure.
What I’d recommend
If you want the quickest path forward:
- remove network_configuration from the @ecs decorator
- keep vpc_id there if needed
- set subnets / security groups in the deployment or work pool job variables instead
If you want, paste your actual @ecs(...) decorator block and I can help rewrite it into a form that should avoid this error.
Useful links:
- Prefect AWS ECS worker docs
- Prefect docsMat Partridge
05/20/2026, 9:49 AMMarvin
05/20/2026, 9:49 AMMarvin
05/20/2026, 9:51 AMenv, not environment.
So this:
python
@ecs(
work_pool="wp_name",
cpu=16384,
memory=65536,
environment={
"PREFECT_INTEGRATIONS_AWS_ECS_OBSERVER_SQS_QUEUE_NAME": "sqs_name"
}
)
should be:
python
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(
work_pool="wp_name",
cpu=16384,
memory=65536,
env={
"PREFECT_INTEGRATIONS_AWS_ECS_OBSERVER_SQS_QUEUE_NAME": "sqs_name"
},
)
@flow
def my_flow():
pass
Why this matters
The ECS worker/job variable model uses env, and that field already has a default_factory=dict. Passing an unexpected field like environment can contribute to schema generation issues and may be related to the validation error you're seeing.
Next thing to check
If changing environment -> env still gives the same error, then the problem may be a broader bug in the current prefect-aws / Prefect version combination, not your specific values.
In that case, I’d test these in order:
1. Remove env too, leaving only cpu/memory
python
@ecs(
work_pool="wp_name",
cpu=16384,
memory=65536,
)
2. If that still fails, remove cpu/memory as well and test the bare minimum:
python
@ecs(work_pool="wp_name")
That will tell us whether:
- env was the issue
- one of cpu / memory is also triggering the same schema bug
- or the decorator itself is broken in your installed version
My best guess right now
There may be a version-specific bug where the decorator’s variable schema conflicts with Pydantic, even for fields like:
- env
- cpu
- memory
So the most useful next step is:
1. change environment to env
2. if still broken, try @ecs(work_pool="wp_name")
3. tell me:
- your prefect version
- your prefect-aws version
You can get them with:
bash
pip show prefect prefect-aws
or
bash
uv pip show prefect prefect-aws
If you paste those versions, I can help narrow down whether this is a known compatibility issue.
Reference:
- Prefect AWS ECS worker docs
- Prefect docsMat Partridge
05/20/2026, 10:04 AMName: prefect
Version: 3.7.1
Summary: Workflow orchestration and management.
Home-page:
Author:
Author-email: "Prefect Technologies, Inc." <help@prefect.io>
License: Apache-2.0
Location: /<redacted>/.venv/lib/python3.13/site-packages
Requires: aiosqlite, alembic, amplitude-analytics, anyio, apprise, asgi-lifespan, asyncpg, cachetools, click, cloudpickle, coolname, cryptography, cyclopts, dateparser, docker, exceptiongroup, fastapi, fsspec, graphviz, griffe, httpcore, httpx, humanize, jinja2, jinja2-humanize-extension, jsonpatch, jsonschema, opentelemetry-api, orjson, packaging, pathspec, pluggy, prometheus-client, pydantic, pydantic-core, pydantic-extra-types, pydantic-settings, pydocket, python-dateutil, python-slugify, pytz, pyyaml, readchar, rfc3339-validator, rich, ruamel-yaml, ruamel-yaml-clib, semver, sniffio, sqlalchemy, toml, typing-extensions, uvicorn, websockets, whenever
Required-by: prefect-aws, prefect-docker, prefect-slack
---
Name: prefect-aws
Version: 0.7.9
Summary: Prefect integrations for interacting with Amazon Web Services.
Home-page: <https://github.com/PrefectHQ/prefect/tree/main/src/integrations/prefect-aws>
Author:
Author-email: "Prefect Technologies, Inc." <help@prefect.io>
License: Apache License 2.0
Location: /<redacted>/Projects/mwi-data-pipelines/.venv/lib/python3.13/site-packages
Requires: aiobotocore, boto3, botocore, prefect, pyparsing, rich, tenacity, typer
Required-by:Marvin
05/20/2026, 10:04 AMread_github_issues
📊 Tools used:
• read_github_issues (2x)Marvin
05/20/2026, 10:09 AMMarvin
05/20/2026, 10:13 AMnetwork_configuration
- then removed environment / env
- then moved defaults into the template
- and the error did not change
That suggests the failure is not caused by ECS job variables anymore.
Based on the code path in Prefect 3.7.1, Python 3.13 can hit a buggy parameter-validation path where Prefect mishandles Pydantic Field(...) metadata on flow parameters and ends up constructing a field with both:
- default
- default_factory
which produces exactly:
text
cannot specify both default and default_factory
Most likely culprit now
Look at your flow function signature, not the @ecs decorator.
Specifically, check whether any flow parameters are declared with Pydantic Field(...), for example:
python
from pydantic import Field
from prefect import flow
@flow
def my_flow(
x: str = Field(default="abc", description="example"),
):
...
or anything similar using:
- Field(default=...)
- Field(default_factory=...)
- Annotated[..., Field(...)]
Under your current stack:
- prefect==3.7.1
- prefect-aws==0.7.9
- Python 3.13
that appears to be the more likely trigger.
What to try next
1. Temporarily remove all Field(...) usage from flow parameters
Change this kind of pattern:
python
from pydantic import Field
from prefect import flow
@flow
def my_flow(
name: str = Field(default="world", description="Name"),
):
...
to plain Python defaults:
python
from prefect import flow
@flow
def my_flow(
name: str = "world",
):
...
If you need descriptions, put them in the docstring instead.
2. Test the flow with the bare minimum ECS decorator
python
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(work_pool="wp_name")
@flow
def my_flow(name: str = "world"):
return name
3. If that works, add back flow params gradually, but without Field(...).
Why I think this is the issue
Because if the decorator args were the problem, removing them should have changed behavior. Since it didn’t, the next most likely source is flow parameter schema generation.
And your environment is exactly the kind that can expose this:
- newer Prefect
- newer Pydantic behavior
- Python 3.13
Workarounds
In order of practicality:
1. Best immediate workaround: avoid pydantic.Field(...) in flow parameters
2. If possible: run this flow under Python 3.12 instead of 3.13
3. Alternative: deploy/run without the @ecs decorator temporarily to confirm the flow itself validates cleanly
What I’d like to see next
If you paste just the flow function signature (you can redact names/business logic), I can tell you exactly which parameter definition is likely triggering this.
For example, paste something like:
python
@ecs(work_pool="...")
@flow
def my_flow(
...
):
...
and I’ll help pinpoint the bad field quickly.
Relevant docs:
- Prefect docs
- Prefect AWS ECS worker docsMat Partridge
05/20/2026, 10:15 AM@ecs(
work_pool=f"mwi-data-pipelines-{env_switch}-primary",
)
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(global_resources:GlobalResources, tasks: list[str], storage_key: str, partition_date: str, other_args: Optional[dict[str, Any]] = None):Marvin
05/20/2026, 10:16 AMMarvin
05/20/2026, 10:16 AMflow_run_name is written as a plain string:
python
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
In Prefect, flow_run_name should usually be either:
- a format string that Prefect can resolve from parameters, or
- a callable
But here the bigger issue is likely your parameter types, especially this one:
python
global_resources: GlobalResources
and possibly:
python
other_args: Optional[dict[str, Any]] = None
The @ecs wrapper causes Prefect to build a parameter schema for the flow, and if one of those parameter types is a Pydantic model / dataclass / custom type with Field(...) usage or default factories inside it, Prefect 3.7.1 on Python 3.13 can blow up with:
text
cannot specify both default and default_factory
So the likely problem is inside GlobalResources, not the decorator.
What to inspect in GlobalResources
Please check whether that model/class contains fields like:
python
from pydantic import BaseModel, Field
class GlobalResources(BaseModel):
x: dict[str, str] = Field(default_factory=dict)
y: str = Field(default="abc")
or any combination of:
- Field(default_factory=...)
- Field(default=...)
- inherited Pydantic fields
- Annotated[..., Field(...)]
That’s the strongest suspect.
Quick way to confirm
Try temporarily replacing the signature with only plain built-in types:
python
from typing import Any, Optional
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
pass
If that works, then GlobalResources is the trigger.
If it still fails, next reduce further to:
python
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(name="global_pipeline")
def global_pipeline(
storage_key: str,
partition_date: str,
):
pass
That will isolate whether:
- GlobalResources is the issue
- flow_run_name templating is involved
- or there is a deeper validation bug
Most likely root cause ranking
1. GlobalResources contains Pydantic Field(...) / default_factory usage
2. Prefect 3.7.1 + Python 3.13 parameter validation bug
3. less likely: other_args or decorator config
What I recommend next
Please paste the definition of GlobalResources (redacted if needed). That is the most likely place the bad field is hiding.
If you want, I can also help you rewrite GlobalResources into a shape that avoids this Prefect/Pydantic edge case.
Docs:
- Prefect docs
- Prefect AWS ECS worker docsMat Partridge
05/20/2026, 10:17 AMGlobalResources is a dataclass @MarvinMarvin
05/20/2026, 10:17 AMMarvin
05/20/2026, 10:17 AMfield(default_factory=...).
So the next thing I’d inspect is whether GlobalResources looks anything like:
python
from dataclasses import dataclass, field
@dataclass
class GlobalResources:
items: dict[str, str] = field(default_factory=dict)
or has any:
- field(default_factory=...)
- inherited dataclass fields
- nested dataclasses / Pydantic models
- mutable defaults being handled via factories
Given your error:
text
cannot specify both default and default_factory
and the fact that stripping ECS job variables didn’t help, the most likely explanation is:
Prefect 3.7.1 on Python 3.13 is choking while building the flow parameter schema for GlobalResources.
Fastest way to prove it
Temporarily change the parameter to something simpler.
Instead of:
python
def global_pipeline(
global_resources: GlobalResources,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
try:
python
def global_pipeline(
global_resources: dict[str, Any],
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
If that works, GlobalResources is confirmed as the trigger.
What to do if GlobalResources is the issue
You’ve got a few options:
1. Pass a plain dict instead of the dataclass
Simplest workaround.
2. Serialize before passing
Convert the dataclass to a dict before calling the flow:
python
from dataclasses import asdict
global_pipeline(
global_resources=asdict(resources),
...
)
3. Use Python 3.12 instead of 3.13
If changing the type is inconvenient, this may avoid the buggy validation path.
4. Avoid dataclass fields with default_factory
If feasible, simplify the dataclass.
Recommended immediate test
Try this exact change first:
python
from typing import Any, Optional
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
global_resources: dict[str, Any],
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
pass
If that succeeds, we’ve isolated it.
If you paste the GlobalResources dataclass definition, I can tell you exactly which field is the likely offender and suggest the least invasive fix.Mat Partridge
05/20/2026, 10:20 AMMarvin
05/20/2026, 10:20 AMMarvin
05/20/2026, 10:20 AMGlobalResources is not just a simple data object; it contains live resource objects:
- AthenaResource
- MongoResource
- Boto3Resource
Even though they’re plain dataclasses, Prefect still has to build a parameter schema for the flow, and those nested dataclass/resource types are not good flow parameters for a deployed/ECS flow.
Two important reasons:
1. Flow parameters need to be schema-validated / serialized
For a deployment, Prefect treats flow arguments as inputs that may be serialized and sent to remote infrastructure. Resource/client objects usually don’t fit that model.
2. These are runtime resources, not parameters
Things like boto3 clients, DB handles, service wrappers, etc. should generally be created inside the flow/task, not passed as flow parameters.
So this is almost certainly why the @ecs-wrapped flow is failing validation.
What to do instead
Pass only serializable config into the flow, then construct the resources inside the flow.
For example, instead of this:
python
@dataclass()
class GlobalResources:
athena_client: AthenaResource
mongo_data: MongoResource
s3_client: Boto3Resource
@ecs(work_pool="...")
@flow(...)
def global_pipeline(
global_resources: GlobalResources,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
...
do something like this:
python
from dataclasses import dataclass
from typing import Any, Optional
from prefect import flow
from prefect_aws.decorators import ecs
@dataclass
class GlobalResourceConfig:
aws_region: str
mongo_db_name: str
bucket_name: str
@ecs(work_pool="my-pool")
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
resource_config: dict[str, Any],
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
athena_client = make_athena_resource(resource_config)
mongo_data = make_mongo_resource(resource_config)
s3_client = make_s3_resource(resource_config)
...
Or even simpler, don’t pass resource config at all if it can be read from env / blocks / secrets inside the flow.
Best practice for Prefect deployments / remote execution
For remotely executed flows, parameters should usually be:
- strings
- numbers
- booleans
- lists / dicts of simple values
- small serializable config objects
Avoid passing:
- boto3 clients
- database connections
- SDK resource wrappers
- service objects
- instantiated classes representing live runtime state
Practical recommendation for your case
I would change this:
python
global_resources: GlobalResources
to either:
python
global_resources: dict[str, Any]
or better, remove it entirely and rebuild the resources inside the flow.
For example:
python
from typing import Any, Optional
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
athena_client = build_athena_resource()
mongo_data = build_mongo_resource()
s3_client = build_s3_resource()
...
Why it may have seemed unrelated to ECS before
Without remote infrastructure, passing rich Python objects around can sometimes appear to work locally. But once Prefect needs to validate/deploy/run the flow in ECS, those parameters have to go through a stricter schema/serialization path.
That’s where this starts breaking.
My confidence level
High that global_resources is the real issue now.
If you want, I can help you refactor this flow signature into a Prefect-friendly pattern using:
- env vars
- Prefect blocksMarvin
05/20/2026, 10:20 AMMat Partridge
05/20/2026, 11:12 AMfutures.append(global_pipeline.submit_to_work_pool(
prefect_env=prefect_env,
tasks=tasks if tasks else ['global_tasks'],
storage_key=storage_key,
partition_date=partition_date,
other_args=other_args,
))
Engine execution exited with unexpected exception
Traceback (most recent call last):
File "/usr/local/lib/python3.12/site-packages/prefect/flow_engine.py", line 2191, in run_flow
ret_val = run_flow_sync(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/prefect/flow_engine.py", line 2036, in run_flow_sync
return engine.state if return_type == "state" else engine.result()
^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/prefect/flow_engine.py", line 768, in result
raise self._raised
File "/usr/local/lib/python3.12/site-packages/prefect/flow_engine.py", line 1287, in run_context
yield self
File "/usr/local/lib/python3.12/site-packages/prefect/flow_engine.py", line 2034, in run_flow_sync
engine.call_flow_fn()
File "/usr/local/lib/python3.12/site-packages/prefect/flow_engine.py", line 1307, in call_flow_fn
result = call_with_parameters(self.flow.fn, self.parameters)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/prefect/utilities/callables/__init__.py", line 348, in call_with_parameters
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/opt/prefect/event_pipeline.py", line 69, in event_pipeline
futures.append(global_pipeline.submit_to_work_pool(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/prefect/flows.py", line 2752, in submit_to_work_pool
result = create_bundle_for_flow_run(flow=flow, flow_run=flow_run)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/prefect/bundles/__init__.py", line 510, in create_bundle_for_flow_run
"function": _serialize_bundle_object(flow),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/prefect/bundles/__init__.py", line 134, in _serialize_bundle_object
return base64.b64encode(gzip.compress(cloudpickle.dumps(obj))).decode() # pyright: ignore[reportUnknownMemberType]
^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/site-packages/cloudpickle/cloudpickle.py", line 1544, in dumps
cp.dump(obj)
File "/usr/local/lib/python3.12/site-packages/cloudpickle/cloudpickle.py", line 1313, in dump
return super().dump(obj)
^^^^^^^^^^^^^^^^^
TypeError: cannot pickle '_thread._local' objectMarvin
05/20/2026, 11:12 AMMarvin
05/20/2026, 11:16 AMtext
global_pipeline.submit_to_work_pool(...)
...
create_bundle_for_flow_run(flow=flow, flow_run=flow_run)
...
cloudpickle.dumps(obj)
...
TypeError: cannot pickle '_thread._local' object
That means:
submit_to_work_pool() is trying to pickle the flow object itself, not just the parameters.
So even if your parameters are now clean, something referenced by global_pipeline — in its globals, closure, decorators, imported module state, or runtime context — contains a thread-local object.
This is common with things like:
- database/session managers
- boto/session wrappers
- Mongo clients
- logging/context objects
- thread-local config/state
- module-level service singletons
- scoped_session
- some SDK clients
Important distinction
submit_to_work_pool() uses bundling. That means Prefect serializes the flow function and ships it to remote infrastructure.
So this is stricter than normal deployment-by-source.
Most likely cause in your code
Even though you moved GlobalResources inside the flow, your module probably still has some global object or import that pulls in thread-local state.
Typical examples:
python
mongo_client = MongoClient(...)
session = scoped_session(...)
aws_resource = SomeWrapper(...)
logger = some_custom_logger_with_context
or helpers imported from another module that create these at import time.
Key clue from your traceback
The object being pickled is the flow:
text
_serialize_bundle_object(flow)
So the problem is not in the submitted arguments like prefect_env or storage_key. Those are fine.
The problem is that global_pipeline itself is capturing something unpicklable.
What to do next
1. Check for module-level objects in the file containing global_pipeline
Look for anything instantiated at import time, especially:
python
client = ...
engine = ...
session = ...
resource = ...
thread_local = ...
local = threading.local()
If present, move creation of those objects inside the flow or inside tasks.
Bad:
python
mongo = MongoClient(...)
s3 = boto3.client("s3")
@flow
def global_pipeline(...):
...
Better:
python
@flow
def global_pipeline(...):
mongo = MongoClient(...)
s3 = boto3.client("s3")
2. Check imported helper modules too
Even if this file looks clean, imported modules can still define globals at import time.
For example:
python
from my_project.resources import mongo_client
If mongo_client is created at import time, it can poison pickling.
3. Keep flow module “cold” at import time
For flows used with submit_to_work_pool(), the safest pattern is:
- imports only
- constants only
- no live clients/resources/sessions
- build clients/resources inside the flow or tasks
4. Move heavy runtime setup into tasks
This is often the safest pattern:
python
from prefect import flow, task
from prefect_aws.decorators import ecs
@task
def run_step(prefect_env: str, storage_key: str):
import boto3
from pymongo import MongoClient
s3 = boto3.client("s3")
mongo = MongoClient(...)
return "ok"
@ecs(work_pool="my-pool")
@flow
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
run_step(prefect_env, storage_key)
5. Quick local debugging test
In the same environment, try pickling the flow function directly:
python
import cloudpickle
cloudpickle.dumps(global_pipeline)
or possibly:
python
cloudpickle.dumps(global_pipeline.fn)
If that fails, it confirms the flow object/module globals are the issue.
Very likely offenders
Given your earlier setup, I would especially inspect for:Marvin
05/20/2026, 11:16 AMsubmit_to_work_pool() bundling. Bundling is convenient, but it’s much less forgiving about picklability.
If you want, paste the top of the module containing global_pipeline — imports plus any module-level variables/constants — and I can help identify the exact object that is likely causing the _thread._local pickling failure.Mat Partridge
05/20/2026, 11:18 AMMarvin
05/20/2026, 11:18 AMMarvin
05/20/2026, 11:18 AMpython
if os.getenv("PREFECT_DEPLOY_ENV", "test") == "prod":
env_switch = "prod"
else:
env_switch = "test"
It just creates a plain string at import time, and strings are picklable.
So the _thread._local error is coming from something else in the module or something imported by the module.
What definitely is not the issue
- os.getenv(...)
- the if/else
- env_switch
- the formatted work pool name string
What is still likely the issue
Something in the file containing the flow, or one of its imports, is creating a live object at module import time, such as:
- boto3 session/client/resource
- Mongo client
- DB engine/session
- custom resource singleton
- logging/context object
- thread-local storage used by a library
Good next debugging step
In the module with global_pipeline, inspect everything above the flow definition besides simple constants.
Things that are safe:
python
import os
from typing import Any, Optional
ENV = os.getenv("X", "test")
NAME = f"pool-{ENV}"
Things that are suspicious:
python
mongo = MongoClient(...)
s3 = boto3.client("s3")
session = scoped_session(...)
resource = SomeClass(...)
Fastest isolation tactic
Temporarily make a minimal flow in a brand new file:
python
import os
from typing import Any, Optional
from prefect import flow
from prefect_aws.decorators import ecs
if os.getenv("PREFECT_DEPLOY_ENV", "test") == "prod":
env_switch = "prod"
else:
env_switch = "test"
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
print(prefect_env, tasks, storage_key, partition_date, other_args)
Then call:
python
global_pipeline.submit_to_work_pool(
prefect_env="test",
tasks=["global_tasks"],
storage_key="abc",
partition_date="2026-05-20",
other_args=None,
)
If that works, then the issue is definitely elsewhere in your real module or imports.
Most useful thing you can paste next
Paste:
- the imports at the top of the file
- any module-level variables/objects above the flow
You can redact names, but keep object types/patterns. That’s the fastest way to spot the unpicklable thing.Mat Partridge
05/20/2026, 11:20 AMMarvin
05/20/2026, 11:20 AMMarvin
05/20/2026, 11:20 AMsubmit_to_work_pool(), Prefect bundles/pickles the flow object. If your flow’s module or closure references live clients like MongoClient(...), they can break pickling.
Recommended pattern
1. Pass config, not clients
Good flow params:
python
@ecs(work_pool="my-pool")
@flow
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
...
Those are safe because they’re strings/lists/dicts.
2. Build resources inside the flow/task body
For example:
python
from prefect import flow, task
from prefect_aws.decorators import ecs
from pymongo import MongoClient
import boto3
from typing import Any, Optional
def build_mongo_resource(prefect_env: str):
uri = get_mongo_uri(prefect_env)
return MongoClient(uri)
def build_s3_resource(prefect_env: str):
session = boto3.Session(region_name=get_region(prefect_env))
return session.client("s3")
@task
def do_mongo_work(prefect_env: str, storage_key: str):
mongo = build_mongo_resource(prefect_env)
collection = mongo["my_db"]["my_collection"]
return collection.find_one({"storage_key": storage_key})
@task
def do_s3_work(prefect_env: str, storage_key: str):
s3 = build_s3_resource(prefect_env)
return s3.head_object(Bucket="my-bucket", Key=storage_key)
@ecs(work_pool="my-pool")
@flow
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
mongo_result = do_mongo_work(prefect_env, storage_key)
s3_result = do_s3_work(prefect_env, storage_key)
return {"mongo": mongo_result, "s3": s3_result}
Why this is safe
- The flow parameters are serializable
- The live clients are created only when the code is already running in ECS
- Nothing unpicklable is captured in the bundled flow definition
3. Avoid module-level resource construction
This is the main thing to avoid:
python
mongo = MongoClient(...)
s3 = boto3.client("s3")
@flow
def global_pipeline(...):
...
Even if you don’t pass them as parameters, those globals can still get captured when Prefect bundles the flow.
4. Use lazy imports if needed
If a library itself creates problematic state on import, move the import inside the task/function:
python
@task
def do_mongo_work(prefect_env: str, storage_key: str):
from pymongo import MongoClient
mongo = MongoClient(get_mongo_uri(prefect_env))
...
That can help if the import path itself brings in thread-local state.
5. If you have “resource classes”, make them factories
Instead of this:
python
@dataclass
class MongoResource:
client: MongoClient
prefer this:
python
from dataclasses import dataclass
@dataclass
class MongoConfig:
uri: str
database: str
class MongoResource:
def __init__(self, config: MongoConfig):
self.config = config
def get_client(self):
from pymongo import MongoClient
return MongoClient(self.config.uri)
Then inside the flow/task:
python
@task
def read_from_mongo(prefect_env: str):
config = MongoConfig(
uri=get_mongo_uri(prefect_env),
database="my_db",
)
mongo_resource = MongoResource(config)
client = mongo_resource.get_client()
return client[config.database]["collection"].find_one({})
Notice that the object you carry around is config, not a live connection.
Good mental model
Use flow parameters for:
- what environment to run against
- IDs / keys / dates
- simple config flags
- serializable dictionaries
Create at runtime:
- DB clients
- boto clients/resources
- SDK sessions
- open connections
- thread-local state
Even better: separate config from executionMarvin
05/20/2026, 11:20 AMpython
def get_resource_settings(prefect_env: str) -> dict[str, Any]:
if prefect_env == "prod":
return {
"mongo_uri": "...",
"aws_region": "us-east-1",
"bucket": "prod-bucket",
}
else:
return {
"mongo_uri": "...",
"aws_region": "us-east-1",
"bucket": "test-bucket",
}
@task
def process_data(settings: dict[str, Any], storage_key: str):
from pymongo import MongoClient
import boto3
mongo = MongoClient(settings["mongo_uri"])
s3 = boto3.client("s3", region_name=settings["aws_region"])
...
Then the flow does:
python
@ecs(work_pool="my-pool")
@flow
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
settings = get_resource_settings(prefect_env)
process_data(settings, storage_key)
What I would change in your codebase
If your “resources process” currently does something like:
python
def get_resources(prefect_env: str) -> GlobalResources:
mongo = MongoClient(...)
s3 = boto3.client("s3")
...
return GlobalResources(...)
that’s okay only if it is called inside the flow/task body, not at import time and not passed into the flow from outside.
So this is okay:
python
@flow
def global_pipeline(...):
resources = get_resources(prefect_env)
...
But this is not okay:
python
resources = get_resources(...)
@flow
def global_pipeline(...):
...
or:
python
global_pipeline.submit_to_work_pool(global_resources=get_resources(...))
One more important nuance
Even if you call get_resources() inside the flow, if the module containing get_resources has top-level globals like:
python
mongo_client = MongoClient(...)
you can still get pickling failures. So make sure the resource module itself is also “cold” at import time.
Best short rule
- Module top level: constants, env lookups, pure helper functions only
- Inside flow/task: instantiate clients/resources
If you want, paste your current get_resources / resource factory code and I can rewrite it into a safe Prefect pattern.Mat Partridge
05/20/2026, 11:25 AMMarvin
05/20/2026, 11:25 AMMarvin
05/20/2026, 11:26 AMget_resources_configuration(prefect_env) is called inside the flow body or inside a task, and if the module defining it has no top-level boto clients or other live objects.
So this is safe:
python
@flow
def global_pipeline(...):
resources = get_resources_configuration(prefect_env)
provided that get_resources_configuration itself does this only at call time:
python
def get_resources_configuration(prefect_env: str) -> dict[str, object]:
secrets_client = boto3.client("secretsmanager", region_name="...")
...
That local secrets_client should not cause the flow-bundling pickle error by itself, because it is created only when the flow runs.
What would be unsafe
If somewhere in that module you have:
python
secrets_client = boto3.client("secretsmanager", region_name="...")
at the top level, that is a likely culprit.
Or if your returned resource objects already contain live clients instead of config-only data.
Based on your example, ClickHouseResource looks okay if it only stores config and creates the actual client later in `get_client()`:
python
@dataclass
class ClickHouseResource:
host: Optional[str] = None
...
def get_client(self):
return clickhouse_connect.get_client(...)
That is a good pattern.
So the main question is:
Do any of the other resources in get_resources_configuration() create live clients immediately?
For example, these would be suspicious if they instantiate connections during config assembly:
- MongoResource(...) if its __init__ creates MongoClient
- AthenaResource(...) if it builds boto/session objects in __post_init__
- SlackResource(...) if it creates a WebClient immediately
- SnowflakeResource(...) if it opens or stores a connection/session
- DuckDBResource(...) if it opens a DB handle immediately
What is safe vs unsafe
Safe resource object
Stores only primitive config:
python
@dataclass
class MongoResource:
uri: str
database: str
def get_client(self):
from pymongo import MongoClient
return MongoClient(self.uri)
Unsafe resource object
Creates client during init:
python
@dataclass
class MongoResource:
uri: str
def __post_init__(self):
self.client = MongoClient(self.uri)
That second pattern can absolutely lead to pickling trouble if the object gets captured.
What I’d recommend
1. Keep get_resources_configuration() pure-ish
- okay to fetch secrets inside it
- return config objects only
- do not return active client/connection objects
2. Each resource class should lazily create clients
- only in methods like get_client()
- not in __init__, __post_init__, class attributes, or module globals
3. Move secret lookup into a task if needed
If bundling is still failing, one very robust pattern is to move secret retrieval into a task:
python
from prefect import task
@task
def get_resources_configuration(prefect_env: str) -> dict[str, object]:
import boto3
secrets_client = boto3.client("secretsmanager", region_name="...")
...
return ...
Then in the flow:
python
@flow
def global_pipeline(...):
resources = get_resources_configuration(prefect_env)
That can help isolate resource construction to runtime.
But the critical thing here
The current error is happening before the flow is launched in ECS, during bundling of the flow function.
So if get_resources_configuration() is only called inside the flow, then it is probably not the direct issue.
That means the remaining likely causes are:
- a module-level client somewhere in imports
- a resource class that creates a client at import/init time
- imported modules with global singletons
- decorators/helpers capturing live clientsMarvin
05/20/2026, 11:26 AMAthenaResource
- MongoResource
- Boto3Resource
- SlackResource
- Snowflake...
probably creates or stores a live SDK/client object earlier than expected.
Best next diagnostic step
Search your resource classes for any of these patterns:
python
client = ...
session = ...
connection = ...
MongoClient(...)
boto3.client(...)
boto3.Session(...)
clickhouse_connect.get_client(...)
snowflake.connector.connect(...)
WebClient(...)
and check whether they occur in:
- module top level
- class attributes
- __init__
- __post_init__
If yes, that’s likely the problem.
Short answer to your direct question
Should you create the boto secrets client there?
Yes, but only inside the function/task at runtime, like this:
python
def get_resources_configuration(prefect_env: str) -> dict[str, object]:
import boto3
secrets_client = boto3.client("secretsmanager", region_name="us-east-1")
...
That is fine.
No, if it exists at module top level.
If you want, paste one or two of the other resource classes — especially MongoResource and Boto3Resource — because those are strong candidates for where the _thread._local is being introduced.Mat Partridge
05/20/2026, 11:29 AMMarvin
05/20/2026, 11:29 AMMarvin
05/20/2026, 11:29 AMsubmit_to_work_pool().
So yes — earlier it may have been “working far enough” to launch ECS tasks because your resource objects were being handled differently in that path, but now the current blocker is specifically:
text
TypeError: cannot pickle '_thread._local' object
during:
text
create_bundle_for_flow_run(...)
cloudpickle.dumps(flow)
That means there is still something attached to the flow’s module/import graph that is not picklable.
And if your secrets client is only created inside a function call, then I agree it is probably not the direct culprit.
What this means practically
At this point, the likely issue is not your parameters anymore and maybe not even the resource construction inside the flow body.
It’s more likely one of these:
- a module imported by the flow file has a top-level client/session/object
- one of the decorators/utilities used by the flow references thread-local state
- the flow function closes over something unpicklable indirectly
- one of the imported resource classes/modules initializes library state at import time
Important nuance with submit_to_work_pool()
This method bundles the flow object itself. That makes it much more sensitive than source-based deployment.
So even code that runs fine locally or in a normal deployment can still fail with bundling.
Best next move: treat this as a bundling issue, not a resource-passing issue
You have two realistic paths:
Option A: Keep using submit_to_work_pool() and make the flow module fully picklable
This means aggressively minimizing module-level imports/state.
Option B: Prefer source-based deployments instead of bundling
This is often the better fit for real projects with lots of dependencies/resources.
With source-based deployments, Prefect loads your code from source in the runtime environment instead of pickling the flow object and shipping it as a bundle.
That usually avoids this exact class of pickling problems.
My recommendation: use a normal deployment instead of submit_to_work_pool()
Instead of dynamically doing:
python
global_pipeline.submit_to_work_pool(...)
deploy the flow to the ECS work pool and then trigger runs of the deployment.
For Prefect 3.x, the usual path is flow.deploy(...) or prefect deploy.
If you want the programmatic route, it looks like:
python
from prefect import flow
@flow
def global_pipeline(prefect_env: str, tasks: list[str], storage_key: str, partition_date: str, other_args=None):
...
then deploy it separately to your ECS work pool, and trigger deployment runs rather than bundling the live flow object each time.
This is often much more robust.
Why this helps
- submit_to_work_pool() -> pickles flow object
- source-based deployment -> references code location/source instead
That avoids the cloudpickle _thread._local trap.
If you want to keep submit_to_work_pool()
Then the next debugging step is to isolate the exact import causing the issue.
A very effective test is:
1. create a new minimal file with only:
- stdlib imports
- Prefect imports
- the flow
- no project imports
2. test submit_to_work_pool()
3. add imports back one by one until pickling fails
That’s tedious, but it will find the offending import quickly.
Why I’m leaning toward source-based deployment here
Because your flow clearly depends on a richer application/resource layer. That’s normal, but it’s exactly the kind of project where bundling can be fragile.
So short version
- yes, these are probably two separate issues
- you fixed the first oneMarvin
05/20/2026, 11:29 AM_thread._local error is a bundling/pickling issue
- if your boto secrets client is only created inside a function, it’s probably not the problem
- best practical fix: use a source-based deployment rather than submit_to_work_pool()
If you want, I can show you the recommended Prefect 3.x replacement for your current submit_to_work_pool() pattern using:
- flow.from_source(...).deploy(...)
- prefect deploy
- creating a deployment and then triggering runs programmatically
That’s probably the cleanest way forward.Mat Partridge
05/20/2026, 11:30 AMMarvin
05/20/2026, 11:30 AMMarvin
05/20/2026, 11:30 AMtext
global_pipeline.submit_to_work_pool(...)
...
cloudpickle.dumps(flow)
...
TypeError: cannot pickle '_thread._local' object
So the new issue is specifically about the flow object being bundled.
Most likely explanation
When you removed the resource object from the flow signature, you probably also changed where/how resources are created, imported, or referenced. Now the flow module or one of its helpers is capturing a live object or import-time state that cloudpickle can’t serialize.
Because you said:
the process was running before it was pickling the entire resources objectthat means the old code path must have been different enough that the new refactor changed what
global_pipeline references.
What I would inspect immediately
Since this is a new issue after the refactor, compare:
- imports added or moved into the flow module
- helper functions newly referenced by global_pipeline
- resource factory code now imported directly
- anything now stored in defaults, closures, decorators, or module globals
Specific high-probability suspects
1. A helper module imported into the flow file that has a module-level client/session
2. A resource factory function that closes over a client/session/thread-local object
3. A class imported into the module whose class attributes initialize something thread-local
4. A logging/config library with thread-local context introduced into the refactor
What to test right now
Take your current flow file and create a temporary minimal version in the same project:
python
import os
from typing import Any, Optional
from prefect import flow
from prefect_aws.decorators import ecs
if os.getenv("PREFECT_DEPLOY_ENV", "test") == "prod":
env_switch = "prod"
else:
env_switch = "test"
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(name="global_pipeline")
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
return {
"prefect_env": prefect_env,
"tasks": tasks,
"storage_key": storage_key,
"partition_date": partition_date,
}
Then call:
python
global_pipeline.submit_to_work_pool(
prefect_env="test",
tasks=["global_tasks"],
storage_key="x",
partition_date="2026-05-20",
other_args=None,
)
If that works, then the new issue is definitely in one of the imports or helper calls from your real flow file.
Why this is the best next step
Because right now we need to separate:
- flow signature problems
from
- flow module pickling problems
And your latest traceback points squarely at module/function bundling.
Most likely structural fix
If the refactor moved resource construction into the same module as the flow, and that module imports resource classes/factories that initialize SDK state, then:
- keep the flow module extremely minimal
- move heavy resource code into task-local imports or helper functions imported lazily inside the flow/task
For example:
```python
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(name="global_pipeline")
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
from my_project.resources import get_resources_configurationMarvin
05/20/2026, 11:30 AM_thread._local.Mat Partridge
05/20/2026, 11:34 AM@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(prefect_env: str, tasks: list[str], storage_key: str, partition_date: str, other_args: Optional[dict[str, Any]] = None):
futures = []
logger = get_run_logger()
process_events_future = None
resources = get_resources_configuration(prefect_env)
global_resources = GlobalResources(
athena_client=resources["athena_client"],....
)
<http://logger.info|logger.info>(f"Starting global tasks for date: {partition_date}")
if "global_tasks" in tasks or "process_events" in tasks or "datalake_tasks" in tasks:
process_events_future = process_events.submit(
r=global_resources,
partition_date=partition_date,
storage_keys=[storage_key],
)
futures.append(process_events_future)Marvin
05/20/2026, 11:34 AMMarvin
05/20/2026, 11:34 AM.submit() inside the flow is probably not the pickling problem.
This part is normal Prefect usage:
python
process_events_future = process_events.submit(
r=global_resources,
partition_date=partition_date,
storage_keys=[storage_key],
)
Task submission inside a flow is fine.
But there is still a likely problem in this snippet:
python
resources = get_resources_configuration(prefect_env)
global_resources = GlobalResources(
athena_client=resources["athena_client"],
...
)
and then:
python
process_events.submit(r=global_resources, ...)
So while this may not be causing the flow bundling error directly, it is still a risky pattern for task execution, because you are passing a rich Python object containing resource wrappers into a task.
Depending on what those resource objects contain, that can cause serialization/pickling issues at the task boundary too.
So there are really two different concerns here:
1. Current error: flow bundling
This happens before the flow even gets to ECS, during:
text
global_pipeline.submit_to_work_pool(...)
That error is about serializing the flow object, not this process_events.submit(...) call.
So this snippet is probably not the root cause of the current _thread._local error.
2. Future/runtime risk: task argument serialization
Passing global_resources into a task like this may still be a bad idea:
python
process_events.submit(r=global_resources, ...)
because task inputs may also need to be serialized.
What I’d change anyway
Instead of passing global_resources into tasks, pass only simple config and build clients/resources inside the task.
For example, instead of:
python
@task
def process_events(r: GlobalResources, partition_date: str, storage_keys: list[str]):
client = r.clickhouse.get_client()
prefer:
python
@task
def process_events(prefect_env: str, partition_date: str, storage_keys: list[str]):
resources = get_resources_configuration(prefect_env)
global_resources = GlobalResources(
athena_client=resources["athena_client"],
...
)
client = global_resources.clickhouse.get_client()
or even better, skip GlobalResources entirely inside the task and just build the exact resource needed there.
So, is the task-running pattern the issue?
- .submit(...) itself: no, probably not
- passing global_resources into tasks: not ideal, and likely to bite you later
- current _thread._local bundling error: more likely caused by imports/module state than this task submission code
One thing in your snippet to double-check
This line in your pasted Slack formatting:
python
<http://logger.info|logger.info>(f"Starting global tasks for date: {partition_date}")
I assume that’s just Slack mangling and your real code is:
python
<http://logger.info|logger.info>(f"Starting global tasks for date: {partition_date}")
If not, fix that, but I assume it’s just formatting noise.
What I suspect more than this snippet
The likely issue is that global_pipeline references get_resources_configuration, GlobalResources, and imported task functions like process_events, and one of those imported modules has an unpicklable global or thread-local object.
So the flow bundling problem is probably in:
- the module where process_events is defined
- the module where GlobalResources or resource classes are defined
- the module where get_resources_configuration is defined
Recommended safer structure
```python
@task
def process_events(prefect_env: str, partition_date: str, storage_keys: list[str]):
resources = get_resources_configuration(prefect_env)
# build only what this task needs
...
@flow(...)
def global_pipeline(prefect_env: str, tasks: list[str], storage_key: str, partition_date: str, other_args: Optional[dict[str, Any]] = None):
futures = []
logger = get_run_logger()Marvin
05/20/2026, 11:34 AMglobal_resources into tasks is something I would refactor anyway
- the current _thread._local issue is still more likely in imported module state than in .submit(...)
If you want, paste:
- the imports at the top of this flow module
- the signature/definition of process_events
- the definition of get_resources_configuration
and I can help pinpoint the most suspicious pickling source.Mat Partridge
05/20/2026, 11:42 AMMarvin
05/20/2026, 11:42 AMMarvin
05/20/2026, 11:42 AMget_client() inside the task runtime, then it’s less likely that global_resources itself is the cause of the new _thread._local error.
So I’d update the hypothesis:
the new pickling issue is probably not from passing global_resources to process_events.submit(...).
Because your traceback shows the failure happens before task submission matters:
text
global_pipeline.submit_to_work_pool(...)
-> create_bundle_for_flow_run(flow=flow, flow_run=flow_run)
-> cloudpickle.dumps(flow)
That means Prefect is failing while serializing the flow object, not the task arguments.
So the question becomes:
what did the refactor change in the flow’s import graph or closure?
Since removing the resource object from the flow signature introduced this earlier failure, the new issue is likely one of:
- a new import added to the flow module
- a helper now referenced directly by the flow
- a decorator/wrapper around the flow/task
- a module-level object in one of the imported modules
- a closure over something from the caller context
What is probably not the culprit
- process_events.submit(...) itself
- lazy get_client() methods
- the simple env_switch logic
- serializable flow parameters like strings/lists/dicts
What I would focus on now
The actual difference between the working-before and failing-now versions.
Specifically:
1. what changed in the flow signature
2. what imports were added/removed
3. whether submit_to_work_pool() is now being called from a different module/context
4. whether get_resources_configuration / GlobalResources / process_events are imported from modules with top-level state
Best diagnostic target now
Not the flow body logic — the flow module imports and the module that calls submit_to_work_pool().
Because bundling the flow can fail if any referenced global in the flow’s function globals is unpicklable.
Concrete thing to inspect
In Python, cloudpickle.dumps(flow) can fail if flow.fn.__globals__ contains references to problematic imported objects.
That means any of these names used by the flow are suspects:
- get_resources_configuration
- GlobalResources
- process_events
- get_run_logger
- any constants/helpers referenced inside the flow
And if those come from modules with top-level initialized state, bundling can break.
Strong next step
Please paste the imports for the module containing global_pipeline.
Something like:
python
import ...
from ... import ...
That is now the most useful thing to review.
Because at this stage, the bug is much more likely to be caused by one imported symbol dragging in a module with thread-local state than by the task submission pattern itself.
My current conclusion
You’re right to push back on the resource-object theory as the primary cause of this new error.
The current _thread._local failure is more likely:
- import-related
- closure/global-related
- bundling-related
rather than task-argument-related.
If you send the imports, I’ll review them specifically for common unpicklable patterns.Mat Partridge
05/20/2026, 11:45 AMMarvin
05/20/2026, 11:45 AMMarvin
05/20/2026, 11:45 AMpython
from connectors.resources_configuration import get_resources_configuration
then the most likely explanation is:
importing connectors.resources_configuration pulls in something with thread-local state at module import time.
Even if get_resources_configuration() itself only creates clients inside the function, the module import can still be the problem.
For example, connectors/resources_configuration.py might import other modules like:
- Mongo helpers
- boto helpers
- clickhouse helpers
- slack client helpers
- snowflake connectors
and one of those imports may initialize something globally.
So yes — this new import is now my top suspect.
Why this happens
When Prefect bundles the flow for submit_to_work_pool(), it pickles the flow function and the globals it references.
By referencing get_resources_configuration, the flow now depends on the imported module path behind it.
If that module or its transitive imports contain a _thread._local object somewhere in module state, cloudpickle can fail.
Best fix to try first
Instead of importing it at module level:
python
from connectors.resources_configuration import get_resources_configuration
move the import inside the flow:
python
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
from connectors.resources_configuration import get_resources_configuration
futures = []
logger = get_run_logger()
resources = get_resources_configuration(prefect_env)
...
This is often enough to avoid the bundling problem, because the flow module no longer captures that imported symbol at definition time.
If that still fails
Then connectors.resources_configuration may still be getting pulled in some other way, or another imported symbol is the issue.
In that case, next step is to inspect connectors/resources_configuration.py for:
- top-level instantiated objects
- imports of modules that instantiate objects at top level
- module-level clients/sessions/resources
- class-level client creation
- anything using thread local/session/context machinery
What to look for in that module
Safe:
python
import boto3
def get_resources_configuration(prefect_env: str):
secrets_client = boto3.client("secretsmanager")
...
Suspicious:
python
secrets_client = boto3.client("secretsmanager")
or:
python
from connectors.mongo import mongo_client
where mongo_client is created at import time.
Also suspicious:
python
slack_client = WebClient(...)
session = scoped_session(...)
client = clickhouse_connect.get_client(...)
mongo = MongoClient(...)
at module scope anywhere in that import chain.
Most likely practical outcome
I would try this in order:
1. Move the import inside the flow
2. If needed, move it even further inside the task that actually needs it
3. Audit connectors.resources_configuration and its imports for module-level clients
So yes: I think you found the likely trigger.
The new import:
python
from connectors.resources_configuration import get_resources_configuration
is the strongest suspect for the _thread._local pickling failure.
If you want, paste connectors/resources_configuration.py imports and top-level code, and I’ll help identify the specific problematic import.Mat Partridge
05/20/2026, 11:57 AMMarvin
05/20/2026, 11:57 AMMarvin
05/20/2026, 11:58 AMpython
with _pickle_local_modules_by_value(flow):
bundle = {
"function": _serialize_bundle_object(flow),
}
So the issue is definitely in something reachable from the flow object:
- its function globals
- referenced imports
- closure
- decorators/wrappers
- module state from imported symbols
Not in the runtime execution of the flow body yet.
What _pickle_local_modules_by_value(flow) implies
Prefect is trying to register local modules used by the flow so they can be bundled by value. During that process, cloudpickle walks the flow and its references.
If one of those references leads to a _thread._local, pickling fails immediately.
So this confirms:
- not an ECS networking issue
- not task execution yet
- not a runtime get_client() call yet
- not the submitted parameters
- yes: a bundling/import graph problem
Given your latest info, the strongest suspect remains:
python
from connectors.resources_configuration import get_resources_configuration
or something imported by that module.
Because that was the meaningful change before this new error appeared.
What to do next
The best immediate mitigation is to remove that symbol from the flow’s global namespace.
Instead of this at module level:
python
from connectors.resources_configuration import get_resources_configuration
use an inner import:
python
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
from connectors.resources_configuration import get_resources_configuration
...
That reduces what the flow object directly references at pickle time.
Why this may help
cloudpickle sees names used by the function. If a problematic object is attached to a module-level import path, deferring the import can keep it out of the global references captured in the serialized flow.
It’s not guaranteed, but it’s often effective.
If inner import still fails
Then one of these is true:
1. another global import is the real culprit
2. process_events or another referenced task pulls in the same problematic module
3. the module is considered local and still bundled through another reference
In that case, the next step is to inspect the imported modules used by:
- global_pipeline
- process_events
especially any local project modules.
High-signal debugging suggestion
Run a quick binary isolation test by commenting out pieces referenced in the flow body:
1. keep only logger + return
2. add process_events.submit(...)
3. add the inner import
4. add the call to get_resources_configuration
5. add GlobalResources(...)
This helps identify which symbol reference causes the bundle serialization to fail.
Example minimal progression:
python
@flow
def global_pipeline(...):
logger = get_run_logger()
<http://logger.info|logger.info>("hello")
return
then:
python
@flow
def global_pipeline(...):
from connectors.resources_configuration import get_resources_configuration
logger = get_run_logger()
<http://logger.info|logger.info>("hello")
return
If step 1 works and step 2 fails, you’ve confirmed the import is enough by itself.
Bottom line
That traceback confirms this is a flow serialization problem, and the likely trigger is still one of the imported local modules referenced by the flow.
If you want, paste:
- the imports of connectors.resources_configuration
- any top-level code in that module
- the import lines for process_events
and I’ll help spot the likely _thread._local source.Mat Partridge
05/20/2026, 11:58 AMMat Partridge
05/20/2026, 11:58 AMMarvin
05/20/2026, 11:58 AMMarvin
05/20/2026, 11:59 AMos, typing, datetime, etc.)
- Prefect imports
- simple third-party libraries that don’t create runtime state on import
Consider moving these inside the flow/task:
- your own project modules, especially connectors.*, resources.*, clients.*
- anything that may import SDK clients, DB/session code, app config, logging context, etc.
So for your case, I would specifically try moving imports like:
python
from connectors.resources_configuration import get_resources_configuration
from ...
inside the flow or task.
Example:
python
from typing import Any, Optional
from prefect import flow, get_run_logger
from prefect_aws.decorators import ecs
@ecs(work_pool=f"mwi-data-pipelines-{env_switch}-primary")
@flow(flow_run_name="global_pipeline_{storage_key}_{partition_date}", name="global_pipeline")
def global_pipeline(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
from connectors.resources_configuration import get_resources_configuration
from my_project.models import GlobalResources
from my_project.tasks import process_events
futures = []
logger = get_run_logger()
resources = get_resources_configuration(prefect_env)
global_resources = GlobalResources(
athena_client=resources["athena_client"],
...
)
<http://logger.info|logger.info>(f"Starting global tasks for date: {partition_date}")
if "global_tasks" in tasks or "process_events" in tasks or "datalake_tasks" in tasks:
futures.append(
process_events.submit(
r=global_resources,
partition_date=partition_date,
storage_keys=[storage_key],
)
)
Why this helps
It keeps the flow object’s global namespace smaller and cleaner during bundling.
What not to move unless needed
You usually do not need to move:
- from prefect import flow, task, get_run_logger
- from typing import Any, Optional
- import os
Those are not likely to cause _thread._local issues.
Best strategy
Don’t do a huge refactor all at once. Do it incrementally:
1. Move from connectors.resources_configuration import get_resources_configuration inside the flow
2. Test
3. If still failing, move imports for local tasks/models used by the flow inside too
4. If still failing, inspect those imported modules for top-level client creation
One caution
If you move task definitions themselves into the flow, that’s usually not what you want.
Move the imports of task functions if needed, but don’t redefine tasks inside the flow unless you have a strong reason.
So:
Okay
python
def global_pipeline(...):
from my_project.tasks import process_events
Not ideal
python
def global_pipeline(...):
@task
def process_events(...):
...
My recommendation for your case
No, don’t move all modules.
Move your local connector/resource/task imports first, especially the newly introduced ones.
If you want, I can help you make a short “safe at top-level vs move inside flow” list based on your actual imports.Mat Partridge
05/20/2026, 12:01 PMcannot pickle '_thread._local' object
File "/Users/mathewpartridge/Projects/mwi-data-pipelines/prefect_flows/event_pipeline.py", line 69, in event_pipeline
prefect_env=prefect_env,
^^^^^^^^^^^^^
...<5 lines>...
partition_date=partition_date,
File "/Users/mathewpartridge/Projects/mwi-data-pipelines/prefect_flows/event_pipeline.py", line 90, in <module>
event_pipeline(env_override="dev", tasks= ["clickhouse_schemas","process_agentic_campaign_revenue"], storage_key="", partition_date=dates)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: cannot pickle '_thread._local' objectMarvin
05/20/2026, 12:01 PMMarvin
05/20/2026, 12:02 PMtext
File ".../event_pipeline.py", line 90, in <module>
event_pipeline(...)
That means the problem may be tied to the fact that you are calling the parent flow at module import/runtime in the same file, not just defining it.
So you have something like:
python
event_pipeline(
env_override="dev",
tasks=["clickhouse_schemas", "process_agentic_campaign_revenue"],
storage_key="",
partition_date=dates,
)
at the bottom of the module.
Why that matters
When the module is imported/executed, that top-level flow invocation can create Prefect runtime context and objects that are not picklable. Then when submit_to_work_pool() runs inside that flow, Prefect tries to bundle another flow and hits _thread._local.
This is especially suspicious because the error points both to:
- the line where submit_to_work_pool() is called
- and the top-level module call to event_pipeline(...)
So yes: this may be the issue.
What you should do
Wrap the direct invocation in a main guard:
python
if __name__ == "__main__":
event_pipeline(
env_override="dev",
tasks=["clickhouse_schemas", "process_agentic_campaign_revenue"],
storage_key="",
partition_date=dates,
)
This is important.
Without that guard, the module executes the flow call as soon as Python imports/runs the file, which can interfere with bundling and create exactly the kind of weird serialization behavior you’re seeing.
Why this can fix it
It prevents the flow from being executed at import time when the module is being analyzed/serialized.
Recommended structure
python
from prefect import flow
@flow
def event_pipeline(...):
...
if __name__ == "__main__":
dates = "2026-05-20"
event_pipeline(
env_override="dev",
tasks=["clickhouse_schemas", "process_agentic_campaign_revenue"],
storage_key="",
partition_date=dates,
)
Very important related note
For any file containing flows, avoid top-level code that:
- runs flows
- creates clients
- loads runtime state
- performs work other than definitions/constants
Top-level should ideally just be:
- imports
- constants
- function/task/flow definitions
My confidence here
Pretty high that this is at least part of the problem, because the traceback explicitly points to the module-level invocation.
Next step
Please try adding the if __name__ == "__main__": guard and rerun.
If it still fails after that, then the next suspects are:
- module-level imports in event_pipeline.py
- imported task modules referenced by event_pipeline
- imported modules used by the child flow being submitted
But the main-guard fix is the first thing I’d do.
Docs:
- Prefect docsMat Partridge
05/20/2026, 12:02 PMMarvin
05/20/2026, 12:02 PMMarvin
05/20/2026, 12:03 PMif __name__ == "__main__":, then the traceback isn’t saying the module-level call is the cause; it’s just showing the local entrypoint where the failure surfaced.
So at this point, the important fact is:
calling global_pipeline.submit_to_work_pool(...) is still forcing Prefect to bundle/pickle the global_pipeline flow object, and something referenced by that flow is unpicklable.
And since you already moved the suspicious import(s) inward, the remaining likely causes are now:
1. another imported local module referenced by the flow
2. the task function being submitted inside the flow
3. the @ecs-wrapped child flow itself
4. Prefect context interacting badly with local submission while inside another flow
The strongest suspect now is actually this pattern itself:
python
futures.append(global_pipeline.submit_to_work_pool(...))
inside another running flow.
Because submit_to_work_pool() bundles the child flow object while you are already inside Prefect runtime context. That can expose context/thread-local state via globals or wrappers more easily than a plain script call.
Important distinction
This is not the same as task.submit().
submit_to_work_pool() is serializing a flow object for remote execution, and that’s a heavier, stricter path.
What I think is most likely now
The object being pickled is probably the @ecs-decorated flow object plus its Prefect/runtime wrapper state, not just your own resources.
You’ve stripped away a lot of the obvious custom suspects already.
So the remaining issue may be a limitation/bug in using submit_to_work_pool() from inside another flow in your current setup.
Best way to test that hypothesis
Try calling the child flow submission from a plain Python script, outside of any parent flow.
For example:
python
from my_module import global_pipeline
if __name__ == "__main__":
global_pipeline.submit_to_work_pool(
prefect_env="dev",
tasks=["global_tasks"],
storage_key="x",
partition_date="2026-05-20",
other_args=None,
)
If that works, but calling it from inside event_pipeline fails, then the issue is specifically the nested flow submission context.
If that fails too
Then the child flow object itself is still not picklable, and we need to isolate which symbol reference on that flow causes it.
Most useful isolation step now
Make a brand-new minimal child flow in the same file and submit that to the work pool:
python
from typing import Optional, Any
from prefect import flow
from prefect_aws.decorators import ecs
@ecs(work_pool="your-pool")
@flow(name="test_child_flow")
def test_child_flow(
prefect_env: str,
tasks: list[str],
storage_key: str,
partition_date: str,
other_args: Optional[dict[str, Any]] = None,
):
return "ok"
Then from your parent flow or plain script:
python
test_child_flow.submit_to_work_pool(
prefect_env="dev",
tasks=["x"],
storage_key="x",
partition_date="2026-05-20",
other_args=None,
)
This test is extremely valuable.
- If minimal child flow works: your real global_pipeline still references something unpicklable
- If minimal child flow also fails: the issue is likely with submit_to_work_pool() + @ecs + current runtime/version context
My recommendation now
Do these in order:
1. test a minimal child flow with @ecs and no project imports
2. test that child flow from a plain script, outside any parent flow
3. if the minimal version works, reintroduce imports one by one
Practical conclusion
You’ve already ruled out a lot of your resource code. At this point the most likely problem is either:
- a remaining symbol referenced by global_pipeline, or
- the submit_to_work_pool() bundling path itself in this nested-flow usageMarvin
05/20/2026, 12:03 PM@flow vs @ecs + @flowMat Partridge
05/20/2026, 12:08 PMException has occurred: TypeError
cannot pickle '_thread._local' object
File "/Users/mathewpartridge/Projects/mwi-data-pipelines/prefect_flows/event_pipeline.py", line 30, in event_pipeline
global_pdasdipeline.submit_to_work_pool()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "/Users/mathewpartridge/Projects/mwi-data-pipelines/prefect_flows/event_pipeline.py", line 100, in <module>
event_pipeline(env_override="dev", tasks= ["clickhouse_schemas","process_agentic_campaign_revenue"], storage_key="", partition_date=dates)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: cannot pickle '_thread._local' object
@ecs(
work_pool=f"",
)
@flow(flow_run_name="global_pipeline", name="global_pipeline")
def global_pdasdipeline():
print(f"Starting global tasks for date:")
global_pdasdipeline.submit_to_work_pool()Marvin
05/20/2026, 12:08 PMMarvin
05/20/2026, 12:09 PMpython
@ecs(work_pool="...")
@flow(flow_run_name="global_pipeline", name="global_pipeline")
def global_pdasdipeline():
print("Starting global tasks for date:")
global_pdasdipeline.submit_to_work_pool()
then your resource code is not the cause of the current pickling failure.
That means the issue is much more fundamental:
either submit_to_work_pool() + @ecs is not working in your current environment, or the surrounding Prefect runtime context is making the flow object unpicklable.
Given what you just showed, the strongest conclusion is:
the problem is with bundling an @ecs infrastructure-bound flow via submit_to_work_pool() in your current setup.
And since your earlier traceback showed Python 3.12 in the runtime container, while your local machine path is macOS and likely a newer Python locally, environment/version behavior may also be involved.
What this test proved
You stripped away:
- custom parameters
- resource objects
- task submission
- helper imports
- local project modules
and it still fails.
So the bug is now very unlikely to be in your application code.
Most likely remaining causes
1. A Prefect bug/limitation with submit_to_work_pool() on infrastructure-bound flows
2. A version/environment incompatibility in the local runtime
3. A known bundling/pickling edge case with the @ecs decorator path
Given the evidence, I would stop trying to “fix” your resource code for this specific error.
What I recommend now
Do not use submit_to_work_pool() for this flow right now.
Instead, use a normal deployment to the ECS work pool and trigger runs from the deployment.
That avoids the bundling path that is blowing up here.
Preferred alternatives
Option 1: deploy the flow to the ECS work pool, then trigger the deployment
In Prefect 3.x, the recommended deployment flow is either:
- prefect deploy
- or programmatic deployment methods
I should verify the exact CLI syntax before recommending commands, but conceptually the pattern is:
- create a deployment for global_pipeline
- target your ECS work pool
- trigger runs of that deployment instead of submit_to_work_pool()
This uses source/deployment-based execution instead of pickling the live flow object.
Why this is the right move
Your minimal reproduction strongly suggests the issue is not in your business logic. Continuing to debug your resources won’t fix a serialization bug in the bundling path.
What not to use
- don’t keep chasing GlobalResources for this specific error
- don’t keep moving imports around hoping the minimal flow will suddenly pickle
You already proved the minimal example fails.
Bottom line
Your latest test indicates:
submit_to_work_pool() itself is the failing path for your @ecs flow in this environment.
So the practical workaround is:
- use a deployment on the ECS work pool
- trigger deployment runs instead of calling .submit_to_work_pool()
If you want, I can help you convert this exact pattern into a Prefect 3.x deployment-based approach and verify the exact commands/API syntax for:
- prefect deploy
- creating the deployment against your ECS work pool
- triggering runs programmatically afterwardMat Partridge
05/20/2026, 12:18 PMMarvin
05/20/2026, 12:18 PMMarvin
05/20/2026, 12:18 PMpython
futures.append(child_flow.submit_to_work_pool(...))
for many child flows, you can gate how many are launched concurrently in Python.
A simple batching pattern:
python
from prefect import flow
@flow
def parent_flow(items: list[str], batch_size: int = 5):
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
futures = []
for item in batch:
futures.append(
child_flow.submit_to_work_pool(
storage_key=item,
)
)
for future in futures:
future.wait()
That limits concurrent child flow launches to batch_size.
2. Limit concurrency at the work pool / worker level
If all subflows go to an ECS work pool, concurrency is also constrained by:
- number of worker processes
- AWS/ECS capacity
- work pool queueing behavior
- any pool-level limits you’ve configured
This is more infrastructure-driven than Prefect-driven.
3. Use Prefect global concurrency limits
If you want a real Prefect-managed concurrency cap across runs, use a global concurrency limit.
This is usually the best answer if you want to say:
- only 3 of these child flows may run at a time
- even across multiple parent flow runs
You’d typically create a global concurrency limit and have the subflows acquire it.
In Prefect 3.x, concurrency limits are documented here:
Global concurrency limits
4. Tag-based concurrency
If your concern is limiting tasks/flows by logical category, Prefect also has tag-based concurrency controls:
Task run concurrency limits
But for subflows submitted to work pools, global concurrency limits are usually the cleaner fit.
Practical recommendation for your case
If you want to limit the number of ECS child flows launched from a parent flow, I’d use one of these:
- simple and explicit: batch them in the parent flow
- centralized and robust: global concurrency limits
Simple batching example
python
@flow
def event_pipeline(storage_keys: list[str], prefect_env: str):
batch_size = 3
for i in range(0, len(storage_keys), batch_size):
batch = storage_keys[i:i + batch_size]
futures = []
for storage_key in batch:
futures.append(
global_pipeline.submit_to_work_pool(
prefect_env=prefect_env,
tasks=["global_tasks"],
storage_key=storage_key,
partition_date="2026-05-20",
)
)
for future in futures:
future.wait()
That ensures only 3 subflows are in flight from this parent at one time.
If you want concurrency across all parent runs too
Then use a global concurrency limit rather than batching alone.
One caveat
If you mean inside each child flow rather than among child flows, that’s a different question:
- task runner controls parallelism inside a flow
- concurrency limits control cross-run/task coordination
So if you want, I can show you either:
1. limit the number of child flows submitted from the parent
2. limit the number of child flow runs globally in Prefect
3. limit task concurrency inside each child flow
If you want the exact CLI/UI steps for global concurrency limits, I can verify the current Prefect 3.x command syntax first and give you the exact command.