Release 33 and greater have experimental support for both exporting Parquet files from the SQLite DBs maintained by the gateway and importing those files into ClickHouse. ClickHouse's high-performance query engine and advanced data compression allow gateways to handle larger data sets while the use of Parquet supports sharing indexing work across gateways by reusing their outputs in the form of Parquet files.
Note
This guide assumes you are using Docker Compose on Linux. Other OSes and container orchestration tools can be made to work as well, but are not covered in this document.
Important
ClickHouse 24.8 or later is required (projections on
ReplacingMergeTree are production-safe from 24.8). The
docker-compose.yaml default image (clickhouse-server:26.3) satisfies
this. Operators upgrading from an earlier release of ar-io-node need to
migrate to the consolidated single transactions table (previously split
across id_transactions, owner_transactions, and target_transactions).
The simplest path is to drop those tables and re-import from Parquet.
Operators who want to preserve expires_at state or avoid the re-import
can use the in-place path instead — see In-place upgrade
below.
Below is an example of how to configure your gateway to serve a complete historical ArDrive index and index new ArDrive bundles on an ongoing basis.
Note
While we currently only offer ArDrive Parquet snapshots, we are interested in hearing from users about what other data sets would be useful and may provide more options in the future.
In order to perform the initial import and ongoing export of bundle data items to ClickHouse, configure the gateway with an admin password, ClickHouse password, and bundle indexing filters.
Place the following .env in the root ar-io-node directory:
#ADMIN_API_KEY=<example> # CHANGE THIS VALUE AND UNCOMMENT!
#CLICKHOUSE_PASSWORD=<example> # CHANGE THIS VALUE AND UNCOMMENT!
CLICKHOUSE_URL="http://clickhouse:8123"
ANS104_UNBUNDLE_FILTER='{ "and": [ { "not": { "or": [ { "tags": [ { "name": "Bundler-App-Name", "value": "Warp" } ] }, { "tags": [ { "name": "Bundler-App-Name", "value": "Redstone" } ] }, { "tags": [ { "name": "Bundler-App-Name", "value": "KYVE" } ] }, { "tags": [ { "name": "Bundler-App-Name", "value": "AO" } ] }, { "attributes": { "owner_address": "-OXcT1sVRSA5eGwt2k6Yuz8-3e3g9WJi5uSE99CWqsBs" } }, { "attributes": { "owner_address": "ZE0N-8P9gXkhtK-07PQu9d8me5tGDxa_i4Mee5RzVYg" } }, { "attributes": { "owner_address": "6DTqSgzXVErOuLhaP0fmAjqF4yzXkvth58asTxP3pNw" } } ] } }, { "tags": [ { "name": "App-Name", "valueStartsWith": "ArDrive" } ] } ] }'
ANS104_INDEX_FILTER='{ "tags": [ { "name": "App-Name", "value": "ArDrive-App" } ] }'Run the following in the ar-io-node root directory:
curl -L https://arweave.net/JVmsuD2EmFkhitzWN71oi9woADE4WUfvrbBYgremCBM -o 2025-04-23-ardrive-ans104-parquet.tar.gz
tar -xzf 2025-04-23-ardrive-ans104-parquet.tar.gz
mv 2025-04-23-ardrive-ans104-parquet/* data/parquet
docker compose --profile clickhouse up clickhouse -d
./scripts/clickhouse-import --input-dir data/parquet --all-partitions
docker compose --profile clickhouse downThe import process should take 10 - 20 minutes depending on your hardware and will log progress as it proceeds. One completed, if you have the ClickHouse client installed, you can confirm the data was successfully imported with the following command:
clickhouse client --password <your-password> -h localhost -q 'SELECT COUNT(DISTINCT id) FROM transactions'The query will take a second or two to run and should output 32712311.
The Arweave base layer SQLite DB snapshots are significantly larger than the Parquet files and not as easy to incrementally update, so we distribute them using BitTorrent. You can download them using the torrent client of your choice. Below is an example of doing this using Transmission:
transmission-cli "magnet:?xt=urn:btih:62ca6e05248e6df59fac9e38252e9c71951294ed&dn=2025-04-23-sqlite.tar.gz&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=http%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Fp4p.arenabg.com%3A1337%2Fannounce&tr=https%3A%2F%2Ftracker.bt4g.com%3A443%2Fannounce"Once you have a copy of the SQLite DB snapshot, run the commands below in the ar-io-node root directory.
Warning
This will erase your existing SQLite DB. Be sure to create a copy first if you'd like to preserve it.
tar -xzf 2025-04-23-sqlite.tar.gz
rm data/sqlite/*
mv 2025-04-23-sqlite/* data/sqlitedocker compose --profile clickhouse up -dThis will start the ar-io-node with ClickHouse and automatically export data items to ClickHouse after they are unbundled.
The following GraphQL query will verify that ClickHouse is working as expected by retreiving a data item imported from Parquet:
curl -g -X POST \
-H "Content-Type: application/json" \
-d '{"query":"query { transactions(ids: [\"YSNwoYB01EFIzbs6HmkGUjjxHW3xuqh-rckYhi0av4A\"]) { edges { node { block { height } bundledIn { id } } } } }"}' \
http://localhost:3000/graphql
# Expected output:
# {"data":{"transactions":{"edges":[{"node":{"block":{"height":1461918},"bundledIn":{"id":"ylhb0PqDtG5HwBg00_RYztUl0x2RuKvbNzT6YiNR2JA"}}}]}}}The ClickHouse schema consists of three staging tables
(staging_blocks, staging_transactions, staging_tags) used during
Parquet import, and a single final transactions table. The final table
includes:
- Partitioning by
intDiv(height, 100000)for partition pruning on height-bounded queries. bloom_filterskip indexes onidandtargetfor fast point and recipient-filter queries.bloom_filterskip indexes ontag_namesandtag_values(materialized columns projected fromtagsviaarrayMap(x -> x.N, tags)) for fast tag-filter queries. Skip indexes cannot match lambda expressions directly, so the arrays are projected into their own columns and indexed there.PROJECTION owner_projectionwith bodySELECT *, tag_names, tag_valuessorted by(owner_address, height, block_transaction_index, is_data_item, id)for owner-filtered queries. Thetag_names/tag_valuesmust be listed explicitly in the projection body so the optimizer can serve tag-filtered queries from it — they'reMATERIALIZED, soSELECT *alone would exclude them.Delta + ZSTD(1)codecs on monotonic timestamp columns andLowCardinalityoncontent_typefor reduced storage.
See src/database/clickhouse/schema.sql and
src/database/clickhouse/ttl-schema.sql for the full definition.
By default ClickHouse retains imported transactions indefinitely. Operators
can define TTL rules that expire rows by tag content or by uploader owner
address — useful for short-lived app data (ephemeral chat, test uploads,
specific content types) or for data from a short-retention uploader. A
top-level default_ttl_seconds can apply a fallback TTL to every row that
no rule matched, and individual rules can opt rows out of expiry entirely
with never_expire: true. A top-level l1_never_expires: true keeps all
L1 transactions (is_data_item = 0) indefinitely regardless of
default_ttl_seconds or matching rules.
Rules live in a YAML file at config/clickhouse-ttl-rules.yaml
(override the host path with CLICKHOUSE_TTL_RULES_PATH). The repo ships a
committed template at config/clickhouse-ttl-rules.example.yaml — copy it
to activate rules:
cp config/clickhouse-ttl-rules.example.yaml config/clickhouse-ttl-rules.yaml
# then edit config/clickhouse-ttl-rules.yamlThe real filename is gitignored so operator policies aren't committed.
clickhouse-auto-import mounts the file into the container and loads it at
the top of every import cycle via scripts/clickhouse-load-ttl-rules.py.
Normalized rules are written to four source tables; two dictionaries
(ttl_tag_rules, ttl_owner_rules) layered over the exact-match tables
refresh on a 60–300 s LIFETIME and are used by
migrate_staging_to_final to compute transactions.expires_at on every
staging→final insert. If the file doesn't exist at import time the loader
logs a warning and skips the reload step; any previously loaded rules
remain active, and if no rules have ever been loaded rows get
expires_at = NULL.
flowchart LR
YAML["config/<br/>clickhouse-ttl-rules.yaml"]
Loader["clickhouse-load-<br/>ttl-rules.py<br/>(each import cycle)"]
subgraph Src["source tables"]
direction TB
TS["ttl_tag_rules_src"]
TP["ttl_tag_prefix_rules"]
OS["ttl_owner_rules_src"]
OP["ttl_owner_prefix_rules"]
Settings["ttl_settings<br/>default_ttl_seconds<br/>l1_never_expires"]
end
subgraph Dicts["dictionaries<br/>(60–300s LIFETIME)"]
direction TB
DT["ttl_tag_rules"]
DO["ttl_owner_rules"]
end
Migrate["migrate_staging_to_final<br/>(per partition)"]
Final["transactions.expires_at"]
YAML --> Loader
Loader --> TS
Loader --> TP
Loader --> OS
Loader --> OP
Loader --> Settings
TS --> DT
OS --> DO
DT --> Migrate
TP --> Migrate
DO --> Migrate
OP --> Migrate
Settings --> Migrate
Migrate --> Final
# Optional: applied when no rule matches a row. Omit to keep unmatched rows
# indefinitely (the prior default).
default_ttl_seconds: 2592000 # 30 days
# Optional: keep all L1 transactions (is_data_item = 0) forever, regardless
# of default_ttl_seconds or matching rules. Pair with default_ttl_seconds
# to expire bundled data items while retaining the L1 layer.
l1_never_expires: true
rules:
# Tag rules (field defaults to "tag")
- tag_name: App-Name
tag_value: ephemeral-chat
ttl_seconds: 86400 # 1 day
- tag_name: App-Name
tag_value: test-
match: prefix
ttl_seconds: 3600 # 1 hour
- tag_name: Content-Type # use match: prefix to catch parameterized
tag_value: image/gif # forms like "image/gif; charset=utf-8"
match: prefix
ttl_seconds: 2592000
# Exempt rule: use `never_expire: true` in place of `ttl_seconds` to opt
# matching rows out of expiry entirely (overrides default_ttl_seconds
# and any other TTL rule that might also match).
- tag_name: App-Name
tag_value: ArDrive
match: prefix
never_expire: true
# Owner rules (value is base64url, as operators see on Arweave)
- field: owner_address
value: abcDEF0123...xyz
ttl_seconds: 604800
- field: owner_address
value: test-uploader-
match: prefix
ttl_seconds: 3600Defaults: field is tag, match is exact. A rule must set exactly one
of ttl_seconds (positive integer) or never_expire: true.
- Precedence (first applicable branch wins):
l1_never_expires: trueAND row is L1 (is_data_item = 0) →expires_at = NULL.- Any matching rule with
never_expire: true→expires_at = NULL. - One or more matching TTL rules → shortest
ttl_secondswins. default_ttl_secondsset at the top level → that value is applied.- Otherwise →
expires_atisNULLand the row is kept indefinitely.
flowchart TD
Start([row ready for<br/>migrate_staging_to_final])
L1{"l1_never_expires<br/>AND is_data_item = 0?"}
Exempt{"any matching rule<br/>with never_expire?"}
TTL{"any matching<br/>TTL rule?"}
Default{"default_ttl_seconds<br/>set?"}
KeepNull["expires_at = NULL<br/>(indefinite retention)"]
ShortestTTL["expires_at = now() +<br/>shortest matching<br/>ttl_seconds"]
DefaultTTL["expires_at = now() +<br/>default_ttl_seconds"]
Start --> L1
L1 -->|yes| KeepNull
L1 -->|no| Exempt
Exempt -->|yes| KeepNull
Exempt -->|no| TTL
TTL -->|yes| ShortestTTL
TTL -->|no| Default
Default -->|yes| DefaultTTL
Default -->|no| KeepNull
- Normalization.
tag_nameis lower-cased + trimmed on both sides;tag_valueis trimmed but case-preserving. Ownervalueis stored as the operator-supplied base64url string and compared againstbase64URLEncode(owner_address)at query time — so prefix rules match textually (e.g. a 6-character base64url prefix works), not on raw bytes. - Content-Type parameters. There is no special handling for Content-Type.
To match
image/gif; charset=utf-8usematch: prefixwithtag_value: image/gif. - No backfill in v1. Rules apply only to rows imported after the rules
are loaded; previously-imported rows keep their existing
expires_at(orNULL). - Fail-open. A missing, unreadable, or malformed rules file logs a warning and exits 0; previously loaded rules remain active and imports proceed normally. If ClickHouse rejects a write mid-load, the loader best-effort truncates the four rule tables and retries the dictionary reload with a short backoff. On the happy recovery path the cycle sees no TTL rules; if the retries still can't reload the dictionaries, prefix rules are cleared but exact-match dictionaries may briefly serve stale entries until the next successful load. The loader's stderr identifies which case fired.
clickhouse client --password <your-password> -q 'SELECT * FROM ttl_tag_rules_src'
clickhouse client --password <your-password> -q 'SELECT * FROM ttl_tag_prefix_rules'
clickhouse client --password <your-password> -q 'SELECT * FROM ttl_owner_rules_src'
clickhouse client --password <your-password> -q 'SELECT * FROM ttl_owner_prefix_rules'
clickhouse client --password <your-password> -q 'SELECT * FROM ttl_settings FINAL'Rules don't apply retroactively. To compute expires_at for previously
imported rows, operators can run a mutation against transactions using
the same lookup logic as migrate_staging_to_final:
ALTER TABLE transactions
UPDATE expires_at = <same expression used at import time>
WHERE 1;This is a heavy mutation and should be scheduled off-peak.
Changing expires_at this way does not need a matching change to
transaction_ids, the GraphQL id lookup table: it has no TTL, so its rows are
never removed before the transactions rows they point to.
Operators upgrading an existing deployment who want to avoid the full
drop-and-re-import can use the idempotent ALTERs that ship in
src/database/clickhouse/schema.sql. The import script runs the
expires_at column-add and TTL rewrite on every cycle, so they apply
automatically on first startup. The one manual step is rebuilding
owner_projection with the new body so tag-filter queries can be served
from it:
ALTER TABLE transactions DROP PROJECTION IF EXISTS owner_projection;
ALTER TABLE transactions ADD PROJECTION owner_projection (
SELECT *, tag_names, tag_values
ORDER BY (owner_address, height, block_transaction_index, is_data_item, id)
);
ALTER TABLE transactions MATERIALIZE PROJECTION owner_projection;MATERIALIZE PROJECTION rewrites every part, so it's not run
automatically — that would re-trigger on every import cycle. Track
progress with:
SELECT * FROM system.mutations
WHERE table = 'transactions' AND NOT is_done;Fresh deployments (and deployments that went through the drop + re-import
path) get the correct projection body from CREATE TABLE and do not
need this step.
If a re-import using the consolidated schema causes problems, revert as follows:
# Stop the gateway
docker compose --profile clickhouse down
# Drop the consolidated table
clickhouse client --password <your-password> -q 'DROP TABLE IF EXISTS transactions'
# Check out the pre-consolidation schema and import script. Use the
# commit immediately preceding 50029213 (the PE-9059 consolidation):
git checkout 50029213^ -- \
src/database/clickhouse/schema.sql \
src/database/clickhouse/ttl-schema.sql \
scripts/clickhouse-import
# Re-import from the Parquet snapshot (unchanged source of truth)
docker compose --profile clickhouse up clickhouse -d
./scripts/clickhouse-import --input-dir data/parquet --all-partitions