# Dataset structured filters Datasets support two kinds of filtering: 1. **SQL** — put a `WHERE` clause in your query. This is the most flexible and runs inside DuckDB. 2. **Structured filters** — a typed filter tree sent alongside the query. The service compiles it into a parameterized SQL predicate, so values are bound, never interpolated. The Hub's **Filters** panel builds structured filters for you. The same AST is available in `QueryDatasetRequest.filter` (and `ExportDatasetRequest.filter`), where values are bound as SQL parameters. Project-wide queries filter with SQL `WHERE` clauses instead. ## UI filter builder The Query tab starts with no filters. Use **Add filter**, choose a column and an operator, enter a value, then press **Run**. - **Match all / any** combines the conditions with AND or OR. - Operators are limited to the selected column kind (see below). - `in` and `not in` accept comma-separated values. - Booleans use a true/false selector; timestamps use a date-time input. - **is empty** / **is not empty** are `IS NULL` / `IS NOT NULL` and need no value. - Filters are not applied live; they take effect when the query runs. ## Operators
Operator Enum Values Compiled predicate
Is FILTER_OP_EQ 1 "column" = ?
Is not FILTER_OP_NE 1 "column" <> ?
> FILTER_OP_GT 1 "column" > ?
>= FILTER_OP_GTE 1 "column" >= ?
< FILTER_OP_LT 1 "column" < ?
<= FILTER_OP_LTE 1 "column" <= ?
Contains FILTER_OP_CONTAINS 1 strpos("column", ?) > 0
Starts with FILTER_OP_STARTS_WITH 1 starts_with("column", ?)
Ends with FILTER_OP_ENDS_WITH 1 ends_with("column", ?)
In FILTER_OP_IN 1+ "column" IN (?, …)
Not in FILTER_OP_NOT_IN 1+ "column" NOT IN (?, …)
Is empty FILTER_OP_IS_NULL 0 "column" IS NULL
Is not empty FILTER_OP_IS_NOT_NULL 0 "column" IS NOT NULL
Text operators are **case-sensitive** and literal: `contains` uses `strpos`, so `%` and `_` have no wildcard meaning. Use SQL `ILIKE` if you need case-insensitive matching. ## Groups and negation ```text FilterNode ├── group { any: bool, nodes: FilterNode[] } AND when any = false ├── condition { column, op, values: DatasetValue[] } └── not { node: FilterNode } ``` - An empty AND group compiles to `TRUE`; an empty OR group compiles to `FALSE`. - `not` wraps any node: `NOT ()`. - Column names must be valid identifiers. They are quoted, and all values are parameterized. ## Where filters run This distinction matters when your SQL aggregates or renames columns. ### Single-dataset queries and exports `QueryDataset` and `ExportDataset` apply the structured filter **outside** your `SELECT`: ```sql SELECT * FROM () AS __bh_dataset_query WHERE ``` So the filter sees your query's **output columns**: - With the default `SELECT * FROM dataset`, every schema column plus `__ingested_at` and `__deleted` is available. - If you project `SELECT host, count(*) AS n ...`, only `host` and `n` exist. Referencing `severity` fails; filter on the output name or move the predicate into the SQL `WHERE`. ### Project-wide queries `QueryDatasets` does not use the structured filter builder. Filter inside the SQL with a `WHERE` clause: ```sql SELECT s.host, w.status_code FROM subdomains AS s JOIN "web-services" AS w ON s.host = w.host WHERE s.resolved AND w.status_code BETWEEN 200 AND 299 ``` The per-relation `filters` field on `QueryDatasetsRequest` is reserved and is not supported by the current execution path. ### Views `ExecuteDatasetView` has no filter or time-range fields. Put predicates in the view SQL. ## Time ranges and history
Field Applies to Notes
ingestedAtFrom __ingested_at >= value Same “outside your SELECT” rule as filters.
ingestedAtTo __ingested_at <= value Must not be before ingestedAtFrom .
includeHistory Switches the relation to raw observations Filters and time bounds apply to history too.
The default `SELECT * FROM dataset` includes `__ingested_at`. If you project a subset and set a time range, include `__ingested_at` in the projection, or filter inside SQL. The Hub's date-time inputs use the browser's local time zone. API clients send RFC 3339 timestamps. ## API examples ### AND group ```json { "filter": { "group": { "any": false, "nodes": [ { "condition": { "column": "status_code", "op": "FILTER_OP_GTE", "values": [{ "integerValue": "200" }] } }, { "condition": { "column": "status_code", "op": "FILTER_OP_LT", "values": [{ "integerValue": "300" }] } }, { "condition": { "column": "ip", "op": "FILTER_OP_IS_NOT_NULL", "values": [] } } ] } } } ``` ### OR group with a negation ```json { "filter": { "group": { "any": true, "nodes": [ { "condition": { "column": "severity", "op": "FILTER_OP_IN", "values": [{ "textValue": "high" }, { "textValue": "critical" }] } }, { "not": { "node": { "condition": { "column": "program", "op": "FILTER_OP_EQ", "values": [{ "textValue": "internal" }] } } } } ] } } } ``` `integerValue` is an int64 and therefore a JSON string. Timestamps are RFC 3339 strings inside `timestampValue`. ## Value types
Column kind Value field Example
Text textValue {"textValue":"api.example.com"}
Integer integerValue (string) {"integerValue":"200"}
Number numberValue {"numberValue":9.8}
Boolean booleanValue {"booleanValue":true}
Timestamp timestampValue {"timestampValue":"2026-01-01T00:00:00Z"}
Values must match the column kind. The UI builds the correct value type from the schema; API clients should do the same. A mismatch surfaces as a DuckDB binder or conversion error. SQL `NULL` comparisons never match, so `is = null` is not a useful filter — use **is empty** (`IS NULL`). ## Errors
Error Cause
Invalid dataset filter Unsupported operator, wrong number of values, or an empty value (except null checks).
DuckDB could not prepare the query The column does not exist in the filtered scope, or a value type does not match.
Invalid filter for dataset '' A project-wide RelationFilter failed to compile against that relation.
## Related - [SQL reference](/datasets/sql) — write the query the filter wraps. - [Overview](/datasets/overview#current-state-and-history) — current state vs history. - [API and CLI](/datasets/api-cli) — request shapes.