Helium Mobile Network Data
Helium Mobile Network data is published as Apache Iceberg tables that anyone can query with SQL. The tables are stored as Parquet files in Cloudflare R2 and served through a public Iceberg REST catalog. Point Trino, PyIceberg, Spark, or any other Iceberg-aware engine at the catalog to query data transfer sessions, Hotspot heartbeats, speedtests, bans, and rewards.
The catalog needs no credentials, and R2 does not charge egress, so reads are not billed. The only cost is the compute for your own query engine.
Helium is moving Mobile Network data from gzipped protobuf files in S3 (Oracle Data) to these Iceberg tables. The switch-over is due to complete on November 1, 2026. Until then, some tables are written to both places while the new pipeline is validated against the old one.
These tables cover the Mobile Network only. IoT Network data is not included.
For background on why the data moved and how the pipeline works, read Rebuilding how Helium stores and queries network data on the Helium blog.
How It Works
The stack has three parts:
- Apache Iceberg is the table format. Data lands as immutable Parquet files in object storage, alongside metadata that describes the columns, the partitioning, and which files belong to each committed snapshot.
- An Iceberg REST catalog tracks which tables exist and where each table's current metadata lives. Writers and readers both go through it, so they always agree on what a table contains.
- Trino is the SQL query engine Helium runs. Any engine with Iceberg support can read the same tables.
The Mobile Oracles write to these tables directly. For tables that have finished moving, the Oracles also read their reward inputs from them, so the data you query is the data rewards are based on. Iceberg commits are atomic, so a reader never sees a partially written batch.
Available Tables
Tables are grouped into namespaces, each matching a stage of the pipeline that produces it. The SQL
examples on this page use Trino syntax, with the catalog named mobile as set up in
Connecting to the Catalog.
| Table | Description | Partitioned By | History From |
|---|---|---|---|
data_transfer.sessions | An offload session that passed verification, with carrier, byte counts, and payer | day(report_received_timestamp) | Oct 2024 |
data_transfer.burned_sessions | A Hotspot's traffic billed in one DC burn, with byte and DC counts and its HIP-150 multiplier (null before HIP-150) | day(burn_timestamp) | Apr 2023 |
data_transfer.multiplier_ticket_history | A HIP-150 multiplier ticket, accepted or refused (the table is append-only) | bucket(hotspot_pubkey, 4) | No rows yet |
hotspots.heartbeats | A validated heartbeat, with position and location trust multiplier | day(received_timestamp) | Oct 2023 |
hotspots.speedtests | A Hotspot's validated speedtest result | day(received_timestamp) | Sep 2023 |
hotspots.speedtest_avgs | A Hotspot's rolling speedtest average | day(timestamp) | Jan 2023 |
hotspots.bans | A ban or unban applied to a Hotspot | day(received_timestamp) | Apr 2025 |
rewards.data_transfer | A Hotspot's data transfer payout for a period, with rewardable bytes and the price used | day(start_period) | May 2023 |
rewards.service_provider | A service provider's payout for a period | day(start_period) | Jan 2024 |
rewards.unallocated | A period's emissions not allocated to any entity | day(start_period) | Jan 2024 |
History in the data_transfer and hotspots tables from before the move to Iceberg was loaded from
the S3 archive and includes only records that passed validation. In backfilled heartbeats,
heartbeat_timestamp equals received_timestamp and asserted_location is null.
rewards.service_provider has no rows after August 30, 2026, because HIP-150 contributes
Service Provider rewards to deployers.
Other Tables
The catalog also holds these tables:
| Table | Description | Partitioned By | History From |
|---|---|---|---|
data_transfer.invalid_sessions | A session that failed verification, with a reason | day(report_received_timestamp) | Jun 2026 |
hotspots.invalid_heartbeats, invalid_speedtests, invalid_speedtest_avgs | A record that failed validation, with a reason | Same as the valid table | Jun 2026 |
hotspots.usage_stats | A carrier's daily usage report for a Hotspot: users and bytes, in total and per carrier | day(epoch_start_timestamp) | Dec 2025 |
hotspots.enabled_carriers_history | A report of which carriers a Hotspot has enabled for offload and sampling | day(received_timestamp) | Oct 2025 |
rewards.proof_of_coverage | A Hotspot's Proof-of-Coverage reward for a period, through July 5, 2026 | day(start_period) | Apr 2023 |
rewards.covered_hexes | One hex a Hotspot covered in a Proof-of-Coverage reward period, through July 5, 2026 | day(start_period) | Jul 2024 |
Each invalid_* table has the same columns as its valid counterpart, plus reason.
hotspots.invalid_bans also exists but has no rows yet.
To list the namespaces, the tables in each, and a table's current schema, ask the catalog:
SHOW SCHEMAS IN mobile;
SHOW TABLES IN mobile.hotspots;
DESCRIBE mobile.hotspots.heartbeats;
SELECT * FROM mobile.information_schema.columns WHERE table_schema = 'hotspots';
The listings also include items this page does not cover, such as one-off backup tables and the
poc schema, which holds compatibility views.
Filter on the Partition Column
Always filter on a table's partition column. That predicate lets Iceberg skip files that cannot match. Without it, a query reads the whole table, however narrow the result looks.
SELECT hotspot_pubkey, count(*)
FROM mobile.hotspots.heartbeats
WHERE received_timestamp >= TIMESTAMP '2026-09-21 00:00:00 UTC'
AND received_timestamp < TIMESTAMP '2026-09-22 00:00:00 UTC'
GROUP BY hotspot_pubkey
The filter must be on the partition column itself. Filtering on a different timestamp in the same row prunes nothing, even when the two values move together.
data_transfer.multiplier_ticket_history is the exception. It is bucketed by hotspot_pubkey, so a
filter on the pubkey prunes it and a date range does not.
Keep timestamp literals to six or fewer fractional digits. Trino gives a literal with more digits a
higher precision, such as timestamp(9), and casts the column up to match, which turns pruning off
and scans every partition.
Reading the Data
- Hotspot key columns have different names. The Hotspot's key is
data_transfer_event_pub_keyindata_transfer.sessionsanddata_transfer.invalid_sessions,pub_keyindata_transfer.burned_sessions,pubkeyinhotspots.bansandhotspots.invalid_bans,hotspot_keyinrewards.data_transfer,rewards.proof_of_coverage, andrewards.covered_hexes, andhotspot_pubkeyin the otherhotspotstables anddata_transfer.multiplier_ticket_history. - Reward amounts are integers in the reward token's smallest unit. Reward periods from January
29, 2025 onward are paid in HNT: amounts are in bones (1 HNT = 100,000,000 bones), and
priceinrewards.data_transferis the HNT price in USD multiplied by 10^8. Earlier periods were paid in MOBILE, which has 6 decimals, andpriceis the MOBILE price multiplied by 10^6. - Speedtest speeds are in bytes per second. That includes columns whose names end in
_bps. Latency is in milliseconds. - Carriers appear as numbered IDs. Carrier columns hold values such as
carrier_1, not carrier names. hotspots.usage_statscan repeat a report. The same Hotspot and day can appear more than once. Keep the latest row perhotspot_pubkeyandepoch_start_timestamp, byreceived_timestamp, before summing.
Connecting to the Catalog
The catalog is a standard Iceberg REST endpoint. Every client needs the same values:
| Setting | Value |
|---|---|
| Catalog URI | https://catalog.mobile.helium.io/api/catalog |
| Warehouse | mobile |
| Storage endpoint | https://helium.io |
| Storage region | us-east-1 |
| Path-style | false |
| Access key | anonymous |
| Secret key | anonymous |
These are the live values, not placeholders. The catalog is unauthenticated, and the storage
credentials are the literal string anonymous, so there is nothing to request or keep secret.
- Trino
- PyIceberg
- Spark
Add a catalog file to your Trino server. See the Trino Iceberg connector docs for every available property, and Deploying Trino to stand up a server.
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=https://catalog.mobile.helium.io/api/catalog
iceberg.rest-catalog.security=NONE
iceberg.rest-catalog.warehouse=mobile
iceberg.rest-catalog.vended-credentials-enabled=false
iceberg.security=READ_ONLY
fs.native-s3.enabled=true
s3.region=us-east-1
s3.endpoint=https://helium.io
s3.path-style-access=false
s3.aws-access-key=anonymous
s3.aws-secret-key=anonymous
Tables are then addressed with the catalog name in front, for example mobile.hotspots.heartbeats.
A second .properties file adds a second catalog, and Trino can join across them, so you can query
this data alongside your own in a single statement.
PyIceberg is the lightest option and needs no cluster. Define the
catalog in ~/.pyiceberg.yaml:
catalog:
mobile:
type: rest
uri: https://catalog.mobile.helium.io/api/catalog
warehouse: mobile
s3.endpoint: https://helium.io
s3.region: us-east-1
s3.path-style-access: false
s3.access-key-id: anonymous
s3.secret-access-key: anonymous
Or pass the same properties inline:
from pyiceberg.catalog import load_catalog
catalog = load_catalog("mobile", **{
"type": "rest",
"uri": "https://catalog.mobile.helium.io/api/catalog",
"warehouse": "mobile",
"s3.endpoint": "https://helium.io",
"s3.region": "us-east-1",
"s3.path-style-access": "false",
"s3.access-key-id": "anonymous",
"s3.secret-access-key": "anonymous",
})
table = catalog.load_table("hotspots.heartbeats")
df = table.scan(
row_filter=(
"received_timestamp >= '2026-09-21T00:00:00+00:00' "
"and received_timestamp < '2026-09-22T00:00:00+00:00'"
),
limit=10,
).to_arrow()
PyIceberg addresses tables within the loaded catalog (hotspots.heartbeats), while Trino puts the
catalog name in front (mobile.hotspots.heartbeats).
Set the catalog as spark.sql.catalog.* properties in spark-defaults.conf or as --conf flags:
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
spark.sql.catalog.mobile=org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.mobile.type=rest
spark.sql.catalog.mobile.uri=https://catalog.mobile.helium.io/api/catalog
spark.sql.catalog.mobile.warehouse=mobile
spark.sql.catalog.mobile.io-impl=org.apache.iceberg.aws.s3.S3FileIO
spark.sql.catalog.mobile.s3.endpoint=https://helium.io
spark.sql.catalog.mobile.s3.region=us-east-1
spark.sql.catalog.mobile.s3.path-style-access=false
spark.sql.catalog.mobile.s3.access-key-id=anonymous
spark.sql.catalog.mobile.s3.secret-access-key=anonymous
Then query with spark.sql(), filtering on the partition column as shown in
Filter on the Partition Column.
You need both iceberg-spark-runtime and iceberg-aws-bundle on the classpath. Without the AWS
bundle, the catalog resolves but every read fails at the storage layer.
Things to Know
warehousemust bemobile. It is the warehouse name the REST catalog knows, not a bucket name. With any other value, the catalog connects but lists no tables.- Vended credentials are off. The catalog does not return per-table storage credentials, so your client must supply the storage settings above for every table it reads.
- The catalog is rate-limited. A client that sends too many requests in a short time has some of them rejected.