Data I/O
Choose the right data reference
Use flyte.io.File for one object, flyte.io.Dir for a directory tree, and flyte.io.DataFrame for a structured dataset that can be encoded by a registered dataframe handler. The following task writes bytes directly to task storage and returns a File reference; it does not first create a local file:
@env.task
async def create_new_remote_file(content: str) -> File:
f = File.new_remote()
async with f.open("wb") as fh:
await fh.write(content.encode("utf-8"))
print(f"Created new remote file at: {f.path}")
return f
@env.task
async def read_file_async(f: File) -> str:
async with f.open("rb") as fh:
contents = bytes(await fh.read()) # read() returns a memoryview
text_content = contents.decode("utf-8")
return text_content
File and Dir are Pydantic BaseModel/SerializableType references. Their literal transformers serialize references as Flyte blobs; ordinary uploads, reads, and downloads happen through their explicit methods (or through a lazy uploader created by from_local). The path field may therefore be local or remote, and name defaults to the basename of that path.
Read and write files
Stream a remote file
Use the async API in an async task and the _sync API in a synchronous task. File.open() is an async context manager; File.open_sync() is a normal context manager.
@env.task
def read_file_sync(f: File) -> str:
with f.open_sync("rb") as fh:
content = fh.read()
return content.decode("utf-8")
@env.task
async def write_file() -> File:
f = File.new_remote()
async with f.open("wb") as fh:
await fh.write(b"Hello, World!")
return f
open() accepts mode, block_size, cache_type, cache_options, compression, and additional fsspec open arguments. open_sync() exposes the same options. Async reads commonly return a memoryview, so convert it with bytes(...) before decoding or passing it to an API that expects bytes.
File.new_remote(file_name=None, hash_method=None) obtains a generated raw-data path from the active task context. File.named_remote(name) instead derives a stable path from the active task/run context and is intended for retry-stable outputs. It raises ValueError when there is no active task execution context. File.from_existing_remote(remote_path, file_cache_key=None) only creates a reference to an existing object:
@env.task
async def process_existing_file() -> str:
file = File.from_existing_remote("s3://my-bucket/data.csv")
async with file.open("rb") as f:
content = await f.read()
return bytes(content).decode("utf-8")
Upload and download local files
Use File.from_local() in an async task and File.from_local_sync() in a synchronous task. Both require the local file to exist; a missing path raises ValueError. An optional remote_destination selects the destination, while hash_method can supply a cache-key computation or a precomputed string.
@env.task
async def upload_file() -> File:
with open("/tmp/data.csv", "w") as f:
f.write("col1,col2\n1,2\n3,4\n")
return await File.from_local("/tmp/data.csv")
@env.task
def upload_file_sync() -> File:
with open("/tmp/data.csv", "w") as f:
f.write("col1,col2\n1,2\n3,4\n")
return File.from_local_sync("/tmp/data.csv")
Outside a raw-data task context, from_local() and from_local_sync() install a lazy uploader. The local path can remain unchanged until dataframe/file literal conversion invokes that callback; in the main local run mode the callback can deliberately return the local path without uploading. Within a task with raw-data storage, the methods upload immediately or use the configured local storage optimization.
Download to a generated local path or provide a destination:
@env.task
async def download_file(f: File) -> str:
local_path = await f.download()
with open(local_path, "r") as fh:
return fh.read()
@env.task
def download_file_sync(f: File) -> str:
local_path = f.download_sync("/tmp/myfile.csv")
with open(local_path, "r") as fh:
return fh.read()
exists() and exists_sync() query the underlying storage filesystem. For a local source, downloading to the same path returns or copies through the local filesystem; for a remote source, the storage layer performs the download.
Attach a custom cache key
HashFunction adapts a callable to Flyte's HashMethod interface:
def my_hash(data: bytes) -> str:
return hashlib.md5(data).hexdigest()
hash_fn = HashFunction.from_fn(my_hash)
hash_fn.update(b"hello")
assert hash_fn.result() == "5d41402abc4b2a76b9719d911017c592"
update(data) calls the supplied function and replaces the previously stored result; it is not an incremental accumulator. File.from_local*, dataframe uploads, and hashing writers use HashMethod implementations to populate literal hashes.
Upload and traverse directories
Dir.from_local() recursively uploads a local directory and Dir.from_existing_remote() creates a reference without uploading. The async lifecycle is:
@env.task
async def upload_directory_from_local() -> Dir:
local_path = await create_test_local_directory()
uploaded_dir = await Dir.from_local(local_path)
print(f"Uploaded local directory {local_path} to remote: {uploaded_dir.path}")
return uploaded_dir
@env.task
async def list_files_in_directory(d: Dir) -> list[File]:
files = await d.list_files()
print(f"Found {len(files)} files in directory {d.path}:")
for file in files:
print(f" - {file.name}: {file.path}")
return files
@env.task
async def walk_directory_async(d: Dir) -> list[File]:
all_files = []
async for file in d.walk(recursive=True):
print(f" Found file: {file.name} at {file.path}")
all_files.append(file)
return all_files
list_files() and list_files_sync() are non-recursive. walk() defaults to recursive traversal and supports recursive and max_depth; walk(recursive=False) internally limits the walk to the top level. Each yielded item is a File reference and can be opened without downloading the whole directory. The synchronous equivalents are walk_sync(), list_files_sync(), get_file_sync(), download_sync(), and exists_sync().
walk_sync() has a file_pattern argument in its public signature, but the current implementation does not apply that pattern when yielding files. Do not rely on file_pattern="*.txt" to filter results. Directory lookup also uses filesystem-specific separators: async get_file() joins with the filesystem separator, while get_file_sync() uses os.path.join.
Upload concurrency can be supplied to the async method:
uploaded_dir = await Dir.from_local(
"/tmp/data_dir/",
remote_destination="s3://my-bucket/data/",
batch_size=16,
)
When batch_size is omitted, the storage upload default is controlled by FLYTE_IO_BATCH_SIZE (default 32). Dir.from_local_sync() has the same upload/reference behavior but does not expose batch_size.
Download a complete tree when local processing needs all of its files:
@env.task
async def download_directory(d: Dir) -> str:
local_dir = await d.download("/tmp/my_data/")
return local_dir
@env.task
def download_directory_sync(d: Dir) -> str:
local_dir = d.download_sync("/tmp/my_data/")
return local_dir
Dir.new_remote(dir_name=None, hash=None) allocates a remote directory reference for output written directly into task storage. Dir.empty() returns an EmptyDir sentinel whose path is ::flyte-empty-dir::. After serialization and deserialization, the result is a plain Dir, so test result.is_empty rather than isinstance(result, EmptyDir). This sentinel is the documented alternative to Optional[Dir], which cannot round-trip through the dataclass transformer in this flow.
Pass structured dataframes
flyte.io.DataFrame is a lazy wrapper around a registered native dataframe type. DataFrame.wrap_df() (and the deprecated from_df() used by existing examples) wraps an in-memory value; wrapping alone does not encode or upload it. Native dataframe values can also be used directly when their plugin transformer is registered.
@env.task
def process_df(df: pl.DataFrame) -> pl.DataFrame:
return df
@env.task
def process_fdf_to_df(df: flyte.io.DataFrame) -> pl.DataFrame:
return df
@env.task
def process_df_to_fdf(df: pl.DataFrame) -> flyte.io.DataFrame:
return flyte.io.DataFrame.from_df(df)
@env.task
def process_lf(lf: pl.LazyFrame) -> pl.LazyFrame:
return lf
@env.task
def process_fdf_to_lf(df: flyte.io.DataFrame) -> pl.LazyFrame:
return df
When the declared type is flyte.io.DataFrame, returning an unwrapped native dataframe causes DataFrameTransformerEngine.to_literal() to raise TypeTransformerFailedError; wrap it first. Conversely, when the declared type is a native registered type such as pl.DataFrame, the engine invokes that type's encoder. DataFrame.from_existing_remote() creates a structured-dataset reference, and DataFrame.from_local()/from_local_sync() attach metadata and upload immediately in a task context or install a lazy uploader outside one.
A wrapper can remain lazy on the receiving side. DataFrame.open(dataframe_type) selects the native target type, all() and all_sync() materialize it, and iter() uses an async-iterator decoder when one is registered. If a wrapper is passed through unchanged, the engine preserves its original structured-dataset literal rather than invoking another encoder.
Column and format annotations become StructuredDatasetType metadata. For example, the Spark integration uses kwtypes to request a column schema:
columns = kwtypes(name=str, age=int)
@spark_env.task
async def sum_of_all_ages(sd: Annotated[pyspark.sql.DataFrame, columns]) -> int:
total_age = sd.groupBy().sum("age").collect()[0][0]
print("Total age sum:", total_age)
return total_age
Before decoding, DataFrameTransformerEngine.to_python_value() uses consumer-side columns when present; otherwise it carries forward incoming literal columns. Decoders receive the resulting metadata and are expected to honor it. A wrapper with a URI is encoded as a structured dataset reference; a local wrapper sent to a remote run needs DataFrame.from_local(...) or an equivalent usable task storage context.
Add a dataframe handler
Implement DataFrameEncoder for Python-to-Flyte conversion and DataFrameDecoder for Flyte-to-Python conversion, then register both with DataFrameTransformerEngine. The encoder constructor takes the native dataframe type, an optional protocol, and an optional format. Its encode() receives a Flyte DataFrame wrapper and a protobuf StructuredDatasetType, and returns a protobuf StructuredDataset. The decoder's decode() receives a protobuf StructuredDataset and current task metadata and returns a native dataframe or an async iterator.
Registration is the dispatch contract:
protocol=Noneregisters an all-fsspec handler under the genericfsspecentry. It cannot be registered withdefault_for_type=True.protocol="s3://"andprotocol="s3"normalize tos3;/normalizes tofile.supported_format=Nonebecomes the generic empty format ("").default_for_type=Truesets both default format and storage;default_format_for_type=Trueanddefault_storage_for_type=Trueset only the corresponding default.- Duplicate
(dataframe type, protocol, format)registrations requireoverride=True; an empty protocol string is rejected in favor ofNone.
Handler lookup first tries an exact protocol and format, then protocol-specific generic/default formats, then generic fsspec handlers and their generic/default formats. open_as() calls a decoder that returns one dataframe. iter_as() is stricter: the decoder result must be an actual async generator or the engine raises ValueError.
Use the bundled dataframe integrations
Polars and Parquet
The Polars plugin registers generic-protocol Parquet handlers for both eager and lazy frames. Eager pl.DataFrame values are written with write_parquet() to <uri>/00000; pl.LazyFrame values use sink_parquet() and write to <uri>/00000.parquet. The corresponding decoders use read_parquet() and scan_parquet(). The lazy decoder applies requested columns with select(*columns) without eagerly materializing the frame.
The different filenames are observable in the round-trip tests: the eager test asserts 00000 exists and 00000.parquet does not, while the lazy test restores a pl.LazyFrame. Partitioning is not implemented in these handlers. Storage credentials are converted to Polars' flat string-key options for supported object stores; when an S3 read raises an exception whose class name is NoCredentialsError, the handlers retry with anonymous S3 options.
Spark and Parquet
SparkToParquetEncoder obtains a Spark session, sets mapreduce.fileoutputcommitter.marksuccessfuljobs to false, and writes the wrapped raw Spark dataframe with overwrite mode. It chooses the wrapper URI first, then task or internal raw-data paths. ParquetToSparkDecoder reads with spark.read.parquet(path) and calls .select(*columns) when current task metadata declares columns; with no declared columns it returns the full read.
Snowflake and pandas
The Snowflake adapter registers pd.DataFrame for protocol snowflake and the generic format. Its write URI is positional:
snowflake://user/account/warehouse/database/schema/table
The encoder parses those components, obtains a connector connection, and calls write_pandas(). The read path uses the same prefix with a query ID in the final component, calls cursor.get_results_from_sfqid(query_id), and returns fetch_pandas_all(). The read handler does not use current task columns to subset the result.
A task configures the connection fields and injects key-pair material as an environment variable like this:
sf_config = SnowflakeConfig(
user="KEVIN",
account="PWGJLTH-XKB21544",
database="FLYTE",
schema="PUBLIC",
warehouse="COMPUTE_WH",
)
env = flyte.TaskEnvironment(
name="snowflake_example_env",
image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-snowflake"),
secrets=[flyte.Secret(key="snowflake", as_env_var="SNOWFLAKE_PRIVATE_KEY")],
)
@env.task
async def downstream_task(df: pd.DataFrame) -> float:
return df["AGE"].mean().item()
The adapter reads SNOWFLAKE_PRIVATE_KEY and optional SNOWFLAKE_PRIVATE_KEY_PASSPHRASE; the PEM key is converted to DER for connector key-pair authentication. Other authentication options belong in Snowflake connector configuration.
Troubleshoot data I/O
- The file read cannot be awaited. Use
async with file.open(...)andawait fh.read()in async tasks. Usewith file.open_sync(...)andfh.read()in sync tasks. - A local file was not uploaded yet. Outside raw-data context,
from_local*may return a reference with a lazy uploader. Conversion to a literal invokes it; local run mode may intentionally keep the local path. - A native dataframe is rejected for
flyte.io.DataFrame. Wrap it withDataFrame.from_df(...)(or the currentwrap_df()API). Native values are valid when the task annotation is the registered native dataframe type. - No dataframe handler matches. Check the native type, normalized protocol, and format. Use
protocol=Nonefor an all-fsspec handler, notprotocol="", and register duplicates withoverride=True. - Polars cannot find an object. Verify the eager/lazy filename convention and object-store credentials. The anonymous retry is limited to S3
NoCredentialsErrorhandling. - Snowflake parsing fails. Verify the positional seven-component write URI and the query-ID final component used for reads; an absent or empty URI raises
ValueError. - A directory pattern did not filter files.
Dir.walk_sync(file_pattern=...)currently accepts but ignores the pattern. Filter the yieldedFileobjects in task code instead. - An optional directory does not deserialize. Return
Dir.empty()orEmptyDir()and branch onis_emptyafter the value crosses the Flyte boundary.