# Pushing and reading dataset rows from workflows
Bug bounty hunting is a long game. The subdomain you discover today might host
the admin panel that gets you a critical in six months. Datasets let a project
accumulate knowledge across runs: every scan reconciles its observations into
an evolving table of assets.
## Pushing Observations: `step.update_dataset`
```bhw
step.update_dataset(
table = "subdomains",
source = path.file("subs-clean.txt").lines(),
pipeline = transform.trim().lower().unique(),
mapping = {
"host": row.value,
"source": "subfinder",
},
mode = "upsert",
)
```
Parameters:
|
Parameter
|
Meaning
|
table
|
Name of an existing project dataset table
|
source
|
Where the data comes from — a path or typed transform expression
|
pipeline
|
Typed transform producing the records to push
|
mapping
|
{column: expression}
projection from each pipeline record (
row
) into dataset columns
|
mode
|
"upsert"
or
"sync"
|
The workflow only provides source, pipeline, mapping, and mode. Everything
else — identity, reconciliation, stale tracking, provenance, change events — is
owned by the dataset service.
Dataset names must start with a letter and contain only letters, digits, and
hyphens (`live-hosts`, not `live_hosts`). Column names are identifiers and may
contain letters, digits, and underscores, but not the reserved names
`__ingested_at` or `__deleted`.
## Mapping
The mapping dict's keys are dataset column names; each value is an expression
evaluated against the current record, bound as `row`. With a JSONL source,
fields come straight off the record:
```bhw
step.exec(command = ["httpx", "-l", "subs.txt", "-jsonl", "-o", "live.jsonl"])
step.update_dataset(
table = "live-hosts",
source = path.file("live.jsonl").jsonl(),
pipeline = transform.unique(),
mapping = {
"url": row.url,
"status_code": row.status_code,
"title": row.title,
},
mode = "upsert",
)
```
With line-based sources, `row.value` is the raw string:
```bhw
mapping = {"host": row.value}
```
## Modes
`mode` accepts the strings `"upsert"` and `"sync"`. The `dataset.upsert()` /
`dataset.sync()` values are not accepted by `step.update_dataset`.
### `"upsert"`
Create or update records matching known identities; records absent from this
push are left untouched. This is what you want for accumulating observations:
```bhw
# Every nightly run adds new subdomains; existing ones stay.
mode = "upsert"
```
### `"sync"`
The push represents the **complete** current state: identities that were known
but are absent from this push become stale. Use it when the source is
authoritative:
```bhw
# After a full rescan, hosts no longer resolving are marked stale.
mode = "sync"
```
## Reading a Dataset: `step.download_dataset`
`step.download_dataset` exports the current logical state of a dataset into a
file in the activity workspace, so a run can start from knowledge accumulated by
previous runs:
```bhw
step.download_dataset(
table = "subdomains",
destination = path.file("known-subdomains.csv"),
format = "csv",
)
```
Parameters:
|
Parameter
|
Meaning
|
table
|
Name of an existing project dataset table
|
destination
|
Workspace file the export is written to (
path.file(...)
)
|
format
|
"csv"
(default),
"jsonl"
, or
"json"
|
Later steps read the file like any other workspace input, and the step exposes
it as `run.output`. The export covers the current logical state: latest
observation per identity, stale records excluded.
## A Complete Asset-Tracking Workflow
```bhw
workflow(
name = "asset-tracker",
activities = [
activity(
name = "discover-and-record",
triggers = [trigger.cron("0 0 * * *")],
steps = [
# 1. Enumerate.
step.exec(command = ["subfinder", "-d", config.var("target_domain"), "-silent", "-o", "subs-raw.txt"]),
# 2. Normalize.
step.transform(
source = path.file("subs-raw.txt").lines(),
pipeline = transform.trim().lower().unique(),
destination = path.file("subs-clean.txt"),
),
# 3. Record into the project's asset inventory.
step.update_dataset(
table = "subdomains",
source = path.file("subs-clean.txt").lines(),
pipeline = transform.unique(),
mapping = {
"host": row.value,
"source": "subfinder",
"program": config.var("program"),
},
mode = "upsert",
),
],
),
],
)
```
Each night, new subdomains appear in the dataset with their source and program
recorded as provenance; nothing is ever lost between runs. When a later scan
uses `mode = "sync"` after a full validation pass, dead assets age out on
their own instead of polluting your target lists forever.
## Querying the data you pushed
Pushes are only half of the feature — datasets are queried with DuckDB SQL and
typed filters. The Datasets section covers schema design, SQL, filters, views,
and automation:
- [Datasets overview](/datasets/overview): current state vs history, identity,
reserved columns, and limits.
- [Schemas and identity](/datasets/schema): column kinds and identity columns.
- [SQL reference](/datasets/sql): relations, pagination, result types, joins,
aggregates, and time ranges.
- [Structured filters](/datasets/filters): the filter AST used by the Hub and
API.
- [Saved views](/datasets/views): reusable queries across datasets.
- [API and CLI](/datasets/api-cli): `DatasetService` and `bh dataset`.