Thingsboard
name: Tin Can Thingsboard data
description: Thingsboard device telemetry history data is stored in the RAW_DB.THINGSBOARD.THINGBSBOARD_REPORTS table.
This table contains 1 row per device (name) per historical import snapshot. For current state reporting, only the
most recent historical import snapshot should be used, to avoid overstating device data. The full historical import
snapshot can be used to trend data over time, but the default should always be to use the most recent snapshot data.
What this table is
A historical record of device state pulled from ThingsBoard. Every hour, an export of all devices is written to S3 (one CSV per snapshot) and loaded by Airbyte into this table. Each row captures the full state of one device at the moment of that hourly export.
This means:
- The same device (
ID) appears in roughly 24 rows per day — one per hourly snapshot. - "Current state" of a device = the most recent row for that
ID. - "History" of a device = all rows for that
ID, ordered byEXTRACTED_AT. - The table is append-only; old snapshots are never updated or deleted by the pipeline.
Two timestamps, two different meanings
This is the single most important thing to understand before querying.
EXTRACTED_AT is when we pulled the snapshot from ThingsBoard, and it is the same for every device in a given hourly batch. Use this when you want to know "what did the fleet look like at time X."
It is TEXT, not a TIMESTAMP — a uniform 24-character ISO 8601 string like 2026-09-10T22:03:37.914Z. That is not a defect to work around. ISO 8601 sorts lexicographically in the same order as chronologically, so string comparison does everything a timestamp would, and casting the column is actively expensive. See "Filtering this table cheaply" below.
<column>_LAST_ACTIVITY_TIMESTAMP is when ThingsBoard last received that specific telemetry value from the device. Stored as Unix epoch milliseconds (e.g., 1761706509464), not a timestamp type — you'll need to convert. Use these when you want to know "when did this device last report its WiFi RSSI / firmware version / etc."
A device that's offline will keep appearing in new hourly snapshots with stale *_LAST_ACTIVITY_TIMESTAMP values. The snapshot time keeps advancing; the activity timestamps don't. The gap between them is how you measure staleness per-metric.
Converting epoch milliseconds in Snowflake:
Filtering this table cheaply
This is the largest table in the warehouse, and its micro-partitions are ordered by
EXTRACTED_AT because Airbyte appends in snapshot order. A literal string bound on that column
prunes nearly all of it away. The obvious alternatives do not, and the difference is three orders
of magnitude.
Always bound the window first, then reduce inside it.
with recent as (
select *
from raw_db.thingsboard.thingsboard_reports
where extracted_at >= to_char(dateadd('hour', -30, current_timestamp()),
'YYYY-MM-DD"T"HH24:MI:SS')
), latest_snapshot as (
select *
from recent
where extracted_at = (select max(extracted_at) from recent)
)
select *
from latest_snapshot;
Thirty hours is the recommended window — wide enough to survive this pipeline's known gaps, narrow
enough to prune hard. The bound works even though it is computed, because current_timestamp()
resolves at compile time.
Note what the second CTE shows: max(extracted_at) is perfectly fine inside an already-pruned
window. It is only ruinous when it runs unbounded against the whole table.
Measured with EXPLAIN against 2,581 micro-partitions holding 363M rows:
| filter as written | partitions scanned | bytes |
|---|---|---|
= (select max(extracted_at) ...) unbounded |
4,298 | 63.9 GB |
unbounded qualify row_number() = 1 |
2,581 | 33.6 GB |
to_timestamp(extracted_at) >= ... |
1,717 | 30.3 GB |
>= '2026-09-09' and < '2026-09-10' |
192 | 499 MB |
>= '2026-09-10T20:00:00.000Z' |
24 | 62 MB |
The top row is the one to internalize. Asking for "the latest snapshot" the obvious way reads more than the whole table, because Snowflake evaluates the subquery across every partition and then scans again. Bounding first turns that into 62 MB.
Clustering would not help and is not worth proposing: the table is already perfectly ordered on
EXTRACTED_AT (clustering depth 1.0), so a clustering key would bill Automatic Clustering for no
gain. The cost here is compute, not storage — 31 GB is well under a dollar a month.
Columns by category
Identity
| Column | Notes |
|---|---|
ID |
ThingsBoard device UUID. Stable across snapshots — this is the join key for tracking a single device over time. |
NAME |
Device name as set in ThingsBoard. Often a MAC address. Can change if renamed in ThingsBoard, so prefer ID for joins. |
DEVICE_PROFILE |
The ThingsBoard device profile assigned to the device (e.g., Tin Can Devices (Certificate), Tin Can Devices (Customer Activated)). Useful for segmenting fleet by provisioning state. |
BETA_COHORT |
Cohort flag for beta program participation. Mostly 0. |
Firmware
| Column | Notes |
|---|---|
FIRMWARE_VERSION |
Version string the device reports. Empty / 0 if never reported. |
FW_STATE |
Firmware update state. Mixed values: 0 (idle/unknown), DOWNLOADING, and other states the device emits during OTA. Treat as a string. |
FIRMWARE_VERSION_LAST_UPDATED_TIME |
Epoch ms — last time the device reported its firmware version. |
FW_STATE_LAST_ACTIVITY_TIMESTAMP |
Epoch ms — last time FW_STATE was updated. |
Network & connectivity
| Column | Notes |
|---|---|
ONLINE_STATUS |
Boolean (true / false) as ThingsBoard saw it at snapshot time. Devices can flap; use LASTACTIVITYTIME for a more reliable "is this device alive" check. |
WIFI_RSSI |
WiFi signal strength (dBm, negative number — closer to 0 is stronger). 0 typically means never reported. |
LASTACTIVITYTIME |
Epoch ms — last time ThingsBoard received any message from the device. The most useful "is this device alive" field. |
ONLINE_STATUS_LAST_ACTIVITY_TIMESTAMP |
Epoch ms — last time online status flipped. |
WIFI_RSSI_LAST_ACTIVITY_TIMESTAMP |
Epoch ms — last RSSI report. |
LASTACTIVITYTIME_LAST_ACTIVITY_TIMESTAMP |
Epoch ms — meta-timestamp on when LASTACTIVITYTIME itself was updated. Rarely needed; LASTACTIVITYTIME is usually what you want. |
SIP (call functionality)
| Column | Notes |
|---|---|
SIP_STATUS |
SIP registration status. |
SIP_RETRY_COUNT |
Number of SIP registration retries. High values suggest a device struggling to register. |
SIPSERVERCERT |
SIP server certificate state/value. |
SIP_STATUS_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
SIP_RETRY_COUNT_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
SIPSERVERCERT_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
Device health (heap & uptime)
These come from the device's runtime. All numeric; 0 typically means never reported.
| Column | Notes |
|---|---|
UPTIME_S |
Seconds since device boot. Resets to a low number after a reboot — useful for detecting reboots in history. |
HEAP_FREE |
Bytes of free heap memory. |
HEAP_MIN |
Minimum free heap observed (low-water mark). Lower = closer to OOM. |
HEAP_LARGEST_BLOCK |
Largest contiguous free heap block. Low values relative to HEAP_FREE indicate fragmentation. |
WORKER_WDT_REBOOT_COUNT |
Count of watchdog-triggered reboots. Non-zero values are worth investigating. |
UPTIME_S_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
HEAP_FREE_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
HEAP_MIN_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
HEAP_LARGEST_BLOCK_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
WORKER_WDT_REBOOT_COUNT_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
BETA_COHORT_LAST_ACTIVITY_TIMESTAMP |
Epoch ms. |
Ingestion metadata (generally ignore)
These are added by Airbyte and the source-file convention. Useful for debugging the pipeline, not for analysis.
| Column | Notes |
|---|---|
EXTRACTED_AT |
Exception to "ignore" rule — this is the snapshot timestamp and the most useful time field in the table. Same value for every device in a given hourly batch. |
_AIRBYTE_RAW_ID |
Airbyte's per-row unique ID. |
_AIRBYTE_EXTRACTED_AT |
When Airbyte loaded the row into Snowflake. Slightly later than EXTRACTED_AT. Use EXTRACTED_AT for analysis. |
_AIRBYTE_META |
JSON of Airbyte sync metadata. |
_AIRBYTE_GENERATION_ID |
Airbyte generation counter. |
_AB_SOURCE_FILE_URL |
S3 path of the source CSV for this row. Useful when reconciling against the raw export. |
_AB_SOURCE_FILE_LAST_MODIFIED |
When the source CSV was last modified in S3. |
Gotchas to keep in mind
Watch for duplicate-row inflation. Any aggregate (COUNT, SUM, AVG) across the whole table multiplies by the number of snapshots. For "how many devices do we have," you want one row per device — but reduce inside a bounded window, never across the whole table. An unbounded QUALIFY ROW_NUMBER() OVER (PARTITION BY ID ORDER BY EXTRACTED_AT DESC) = 1 gets the right answer by reading 33 GB to do it. Bound on EXTRACTED_AT first, as shown in "Filtering this table cheaply".
Empty strings are not NULLs. The CSV ingestion produces empty strings ('') for missing values in some columns rather than proper NULLs. WHERE col IS NOT NULL won't filter these out — use NULLIF(col, '') or WHERE col <> ''.
Numeric-looking columns are sometimes strings. FW_STATE is the obvious one (mixes 0 with strings like DOWNLOADING), but inspect column types in Snowflake before assuming you can do math. Cast explicitly with TRY_CAST(col AS NUMBER) to fail safely.
0 often means "never reported," not literal zero. Especially for HEAP_*, UPTIME_S, WIFI_RSSI, and WORKER_WDT_REBOOT_COUNT. When analyzing these, consider filtering to rows where the corresponding *_LAST_ACTIVITY_TIMESTAMP is non-zero / non-empty.
ONLINE_STATUS is point-in-time and can flap. For reliable liveness, compute it from LASTACTIVITYTIME against a threshold (e.g., "active in the last 15 minutes") rather than trusting the boolean directly.
NAME is not a stable identifier. It can be edited in ThingsBoard. Use ID for joining across snapshots or to other tables.
Snapshot cadence isn't always exactly hourly. Pipeline delays, retries, and ThingsBoard-side hiccups can produce gaps or doubled snapshots. Don't assume COUNT(*) / COUNT(DISTINCT ID) = 24 per day exactly.