Destinations¶
A destination decides where and how asset data is stored and read back. It is separate from how data is produced, so the same asset can land in a CSV folder, a warehouse or a test double without changing.
Configuring destinations¶
On a source, so every asset without its own inherits it:
On an asset:
A single destination or a list is accepted. With several, every write goes to all of them:
source = Shop(destinations=[
il.CSVDestination(base_path="./data"),
WarehouseDestination(connection=warehouse),
])
Upstream reads use the first resolved destination of the upstream asset.
default_destination_key names a preferred one for the platform and UIs to honour.
Decorators can restrict the destination classes an asset or source accepts; an instance of
another class raises DestinationError at materialization:
Built-in destinations¶
CSVDestination¶
CSV files on the local filesystem, one folder per asset, one file per partition:
{base_path}/{dataset}/{table}/data.csv
{base_path}/{dataset}/{table}/{column}={partition_id}/data.csv
Rows are written as records; the first row's keys become the header. CSV stores strings, so a
read reconciles the rows against the effective schema carried in the context, restoring the
declared types and turning empty strings into None. Window writes are split per partition.
FileDestination¶
Pickled Python objects, one file per asset:
It stores whatever the asset returned, tabular or not, and is not partition-aware: every
write replaces the same file regardless of the partition scope. Use it for unpartitioned assets
or arbitrary objects; use CSVDestination for partitioned local files.
MemoryDestination¶
An in-process store keyed by {dataset}/{table}/{column}={partition_id}, shared by every
instance, meant for tests:
Reading a key that was never written raises DataNotFoundError.
Other destinations come from companion packages; see Ecosystem.
IOContext¶
Every read() and write() receives an immutable IOContext:
| Field | Meaning |
|---|---|
asset |
The asset being read or written. asset.table, asset.dataset, asset.partitioning name the storage location. |
partition_or_window |
The scope of this call, or None for an unpartitioned asset. |
schema |
The effective schema of the data: the declared one, or the one inferred during conform. None when none could be resolved. |
metadata |
Run metadata (run id, backfill id). |
Custom destinations¶
Subclass il.Destination, or decorate a plain class with @il.destination, and implement
read() and write(). Both may be sync or async def:
import json
from pathlib import Path
from typing import Any
import interloper as il
@il.destination(name="JSON files")
class JSONDestination(il.Destination):
base_path: str = ""
def write(self, context: il.IOContext, data: Any) -> None:
path = Path(self.base_path) / context.asset.dataset / f"{context.asset.table}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, default=str))
def read(self, context: il.IOContext) -> Any:
path = Path(self.base_path) / context.asset.dataset / f"{context.asset.table}.json"
return json.loads(path.read_text())
Decorator options: resources, key, name, icon, tags, read_representation,
materialization_strategy. Resource slots are declared as typed attributes or through
resources=; see Resources.
Override partition_row_counts(context) to report rows per partition; asset.partition_row_counts()
and coverage tooling call it.
Partition-aware destinations¶
il.PartitionedDestination implements the partition dispatch once. Subclasses implement two
scope hooks and are partition-correct by construction:
class JSONDestination(il.PartitionedDestination):
base_path: str = ""
def _write_scope(self, context, partition, data) -> None:
# partition is None for the unpartitioned whole
...
def _read_scope(self, context, partition):
...
A window write is split into one _write_scope call per partition, slicing the data through
its representation on the partition column. A window read
returns one result per partition, newest first. CSVDestination and MemoryDestination are
built this way.
Database destinations¶
DatabaseDestination (imported from interloper.destination, together with
WriteDisposition) targets stores addressed by table and schema. Reads and writes reduce to a
small set of row operations:
| Hook | Called for |
|---|---|
_insert(table, schema, rows) |
writing records |
_delete_all(table, schema) |
replacing an unpartitioned asset |
_delete_partition(table, schema, column, value) |
replacing a non-time partition |
_delete_partition_range(table, schema, column, start, end) |
replacing a time partition, by half-open bounds |
_select_all(table, schema) |
reading an unpartitioned asset |
_select_partition(table, schema, column, value) |
reading a non-time partition |
_select_partition_range(table, schema, column, start, end) |
reading a time partition |
_count_by_partition(table, schema, column) |
partition_row_counts |
Optional overrides: _transaction() (a context manager around each write, a no-op by
default), and _insert_data(table, schema, data, context) for backends that load a native
representation directly (a DataFrame into a Parquet load job) using context.schema.
Behaviour the base class owns:
- Write disposition:
write_disposition = WriteDisposition.REPLACE(default) deletes the matching scope before inserting;APPENDnever deletes. A class attribute, not a field. - Time partitions are scoped by bounds, not by equality, because rows of a monthly partition carry daily dates.
- Read representation: rows are materialized into the representation named by
read_representation("rows"by default;"dataframe"for pandas-native backends). - Write-time strategy: the
materialization_strategyfield lets a backend demand schema-shaped data:STRICTvalidates against the effective schema before writing,RECONCILEcoerces,AUTOtrusts the conformed data. It is set as a default via the decorator and overridable per configured destination. - A warning is emitted when the partition column is missing from written data, since downstream reads by partition would then return nothing.
Table naming¶
The table name comes from the owning source's asset_table(); see
Sources. Destinations read context.asset.table and
context.asset.dataset and never compute names themselves.