- Use BigQuery for historical and cross-site analysis of sensor data: energy baselines, equipment comparisons, fault trends and model training over months or years. Keep live dashboards and alarms on an operational time-series store.
- Partition the readings table by day on the reading timestamp and cluster it by site and point. Partition filters let BigQuery skip whole days; clustering prunes blocks inside each day. A table can have up to 10,000 partitions.
- Streaming goes through the Storage Write API (the default stream is at-least-once) or a Pub/Sub BigQuery subscription (also at-least-once), so plan for duplicate readings. Batch loads from Cloud Storage are free by default.
- On-demand queries are billed by bytes scanned (6.25 USD per TiB after the first free TiB a month in us-central1 on 27 October 2026), so table layout and partition filters decide cost. Storage of partitions untouched for 90 days drops to the long-term rate.
- BigQuery is a poor fit for sub-second operational queries, single-row lookups by the second, or frequent updates to individual readings.
Where BigQuery fits in an IoT architecture, and who this is for
An IoT pipeline has three separate jobs. Messaging (MQTT brokers, Kafka, Pub/Sub) moves readings from devices and buffers them. An operational time-series database such as TimescaleDB or InfluxDB holds recent readings for dashboards and alarms. A warehouse such as BigQuery holds the full history for analysis that spans sites, years and other business data.
BigQuery suits the third job because it is serverless (no instances to size), stores data in columns, and bills queries by the bytes they read. A question such as "which chillers in the portfolio used more kWh per cooling degree day this summer than last" scans a few columns over many months, which matches the columnar, scan-billed design the documentation describes.
This page is for data engineers and analysts in facilities, energy or industrial teams who already collect sensor data and want a design for its long-term home in BigQuery. It does not compare BigQuery with other warehouses: for that see BigQuery vs PostgreSQL, ClickHouse vs BigQuery and the BigQuery alternatives guide.
Designing the readings table: partitioning and clustering
Partition by the reading timestamp, not by ingestion time, so late readings land in the day they belong to and partition filters match how people ask questions. For TIMESTAMP columns BigQuery supports hourly, daily, monthly or yearly partitions, with boundaries in UTC. Daily is the default and fits continuous sensor data; Google suggests hourly only for high volumes over short date ranges, and monthly or yearly when each day holds little data.
Two limits shape the choice. A partitioned table can have up to 10,000 partitions, which is about 27 years of daily partitions but only about 14 months of hourly ones. And Google advises against partitions much smaller than about 10 GB, recommending clustering instead when partitions would be tiny. For a small estate, monthly partitions clustered by timestamp can be the better layout.
Then cluster by the columns most queries filter on, in order of importance: typically site or building first, then point. Clustering sorts storage blocks by those columns so a filter on one building skips the other buildings' blocks within each partition. Column order matters: a filter on the second clustering column alone benefits less.
Two table options are worth setting from the start: require_partition_filter, which rejects queries with no filter on the partitioning column, and partition_expiration_days, which deletes each partition once it reaches the age you set. Do not use date-sharded tables such as readings_20261027; Google recommends partitioning over sharding.
CREATE TABLE iot.bms_reading (
ts TIMESTAMP NOT NULL, -- reading time from the device, UTC
building_id INT64 NOT NULL,
point_id INT64 NOT NULL, -- from the asset/point register
value FLOAT64,
quality STRING -- e.g. 'good', 'stale', 'fault'
)
PARTITION BY TIMESTAMP_TRUNC(ts, DAY)
CLUSTER BY building_id, point_id
OPTIONS (
partition_expiration_days = 1825, -- illustrative: five years of raw readings
require_partition_filter = TRUE
);Getting readings in: streaming vs batch loading
BigQuery offers three practical ingestion routes for sensor data:
- Storage Write API (gRPC). Google's recommended API for new projects. Its default stream makes rows queryable as soon as they are written and is at-least-once; for application-created streams the documentation describes exactly-once write semantics through stream offsets (that covers writes into BigQuery, not duplicates your producers send). Send rows in batches per
AppendRowscall, not one at a time. The oldertabledata.insertAllmethod is now called the Storage Write API (REST) and remains supported, at a higher ingestion price. - Pub/Sub BigQuery subscription. If devices or gateways already publish to Pub/Sub, a BigQuery subscription writes each message to an existing table without a subscriber service of your own. It uses the Storage Write API and delivers at least once. Use a Dataflow pipeline instead when you need to window, aggregate or deduplicate across messages before they land.
- Batch loads. Gateways or an edge service write files (Parquet, Avro or CSV) to Cloud Storage and a load job appends them, for example every 15 minutes or nightly. Loading from Cloud Storage is free by default using a shared slot pool with no throughput guarantee.
For building data, one possible split (a design choice, not a Google recommendation) is: alarms and live trends from the operational store, plus hourly or nightly batch loads of the same readings into BigQuery. Streaming earns its cost when analysts need the warehouse to be minutes behind rather than hours. Whichever you choose, at-least-once delivery means duplicates are possible, so deduplicate on (point_id, ts) in a scheduled query or a downstream table rather than assuming each reading arrives once. The Kafka IoT pipeline design covers the upstream side.
Time-series SQL in BigQuery
GoogleSQL has dedicated time series functions: TIMESTAMP_BUCKET and DATE_BUCKET return the lower bound of a fixed-width bucket, and GAP_FILL fills missing buckets with the last observed value (locf), linear interpolation or nulls. That matters for meters and BMS points that report on change or drop out for a few minutes.
For repeated roll-ups, incremental materialized views are refreshed in the background from base-table changes; an hourly summary per point can serve most dashboards and reports at a fraction of the bytes. For anomaly work, BigQuery ML's ML.DETECT_ANOMALIES works with time series models such as ARIMA_PLUS, which keeps simple per-point anomaly checks inside SQL; see time-series anomaly detection for the methods.
SELECT *
FROM GAP_FILL(
(
SELECT point_id,
TIMESTAMP_BUCKET(ts, INTERVAL 1 HOUR) AS hour,
AVG(value) AS avg_kw
FROM iot.bms_reading
WHERE ts >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) -- partition filter
AND building_id = 12 -- clustering filter
AND point_id IN (4101, 4102) -- chiller kW points
GROUP BY point_id, hour
),
ts_column => 'hour',
bucket_width => INTERVAL 1 HOUR,
partitioning_columns => ['point_id'],
value_columns => [('avg_kw', 'linear')]
)
ORDER BY point_id, hour;Sizing the data: a building portfolio example
BigQuery bills storage and on-demand queries on logical bytes, and documents a size for each data type: 8 bytes for TIMESTAMP, INT64 and FLOAT64, and 2 bytes plus the UTF-8 length for STRING. As an illustrative assumption, take a portfolio of 40 buildings with 2,500 BMS points each (100,000 points) reporting once a minute, stored in the table above; substitute your own point count, reporting interval and columns. The arithmetic below ran on PostgreSQL; it shows about 5.1 GiB of new data a day and 1.82 TiB a year, and how much a typical query reads with and without a partition filter.
Only the columns a query references are counted. A query reading ts, point_id and value for a full year would process about 1.15 TiB; the same query limited to the last 7 days processes about 22.5 GiB before clustering prunes it further. That difference is multiplied by every dashboard refresh. Our editorial view: under on-demand billing, the partition filter and clustering are the design choices with the largest effect on query cost.
-- Logical size of one reading row using BigQuery's documented data type sizes:
-- TIMESTAMP 8, INT64 8, INT64 8, FLOAT64 8, STRING 2 + UTF-8 length ('good' = 4)
WITH assumptions AS (
SELECT 100000::numeric AS points, -- 40 buildings x 2,500 BMS points
60::numeric AS interval_s, -- one reading per point per minute
(8 + 8 + 8 + 8 + (2 + 4))::numeric AS row_bytes,
(8 + 8 + 8)::numeric AS scan_bytes -- ts, point_id, value read by a typical query
), daily AS (
SELECT (points * 86400 / interval_s)::bigint AS rows_per_day, row_bytes, scan_bytes FROM assumptions
)
SELECT rows_per_day,
round(rows_per_day * row_bytes / 1073741824, 2) AS gib_per_day,
round(rows_per_day * row_bytes * 365 / 1099511627776, 2) AS tib_per_year,
round(rows_per_day * scan_bytes * 365 / 1099511627776, 3) AS tib_scanned_full_year_3_cols,
round(rows_per_day * scan_bytes * 7 / 1073741824, 2) AS gib_scanned_last_7_days_3_cols
FROM daily; rows_per_day | gib_per_day | tib_per_year | tib_scanned_full_year_3_cols | gib_scanned_last_7_days_3_cols
--------------+-------------+--------------+------------------------------+--------------------------------
144000000 | 5.10 | 1.82 | 1.147 | 22.53Controlling query cost
- Filter on the partition column. Enforce it with
require_partition_filter. Compare thetscolumn itself with a constant expression; a filter that compares it with values from another table or computes it from other columns does not prune partitions. - Select only the columns you need.
SELECT *reads every column; on non-clustered tablesLIMITdoes not reduce the bytes billed. - Set maximum bytes billed on scheduled and ad hoc queries, and project or user daily quotas for on-demand billing. A query whose estimate exceeds the limit fails without charge.
- Expect estimates, not exact figures, on clustered tables. Google notes that the dry-run estimate for a clustered table is an upper bound, because the blocks pruned are only known at run time.
- Serve dashboards from roll-ups. Point BI tools at hourly or daily materialized views, not at raw readings.
- Consider capacity pricing (slots) when query volume is steady and high; it bills compute time instead of bytes scanned.
Security and operational considerations
- Access. Use IAM roles at dataset level: the ingestion service account needs data editor rights on one dataset only, analysts get viewer rights. Keep raw readings and curated tables in separate datasets.
- Location. Choose the dataset region before loading data; it determines where readings are stored and which price list applies. Cross-region loads are billed for network transfer.
- Late and corrected data. Writing into old partitions resets their long-term storage clock and costs DML. Prefer appending corrections with a version column and resolving them in views.
- Quotas. Load jobs and partition modifications have daily quotas; check them before designing many small loads per day.
- Retention. Partition expiration deletes raw readings automatically. Keep the long-lived roll-ups in a separate table with a longer or no expiration.
Limitations and when not to use BigQuery for IoT data
- Live operations. Alarm screens that refresh every few seconds and need the latest value per point want an operational store with low-latency lookups, not a warehouse billed per scan.
- Frequent row updates. Our editorial view: BigQuery suits appends and large scans better than frequent changes to individual readings, which are charged as DML and, in old partitions, reset the long-term storage clock.
- Small estates. A few hundred meters at 15-minute intervals is a few million rows a year. Our editorial view: PostgreSQL is likely to cover that at lower cost and complexity; see How to Store IoT Sensor Data.
- Not on Google Cloud. If the rest of the platform is on AWS or Azure, moving every reading across clouds adds transfer cost and another security boundary. Compare Databricks vs BigQuery and Snowflake vs BigQuery.
- Unpredictable ad hoc scanning. Without partition filters and byte limits, on-demand billing can surprise you.
Pricing
Prices from the provider's official pricing pages, checked 27 October 2026, region us-central1 (Iowa), in USD, excluding tax. List prices only; discounts, commitments and your actual usage change the bill.
List prices from the BigQuery pricing page for us-central1 on 27 October 2026. Storage is shown on that page per GiB-hour; a month is billed at 730 hours (the page's own example gives 23.552 USD for 1 TiB of active logical storage for a full month). Long-term storage applies to a table or partition not modified for 90 consecutive days, which is why daily partitions keep most sensor history at the lower rate. Other regions and multi-regions have different prices.
| Item | List price | Notes |
|---|---|---|
| On-demand queries | 6.25 USD per TiB processed | First 1 TiB a month free |
| Active logical storage | 0.000031507 USD per GiB-hour (about 0.023 USD per GiB-month) | First 10 GiB a month free |
| Long-term logical storage | 0.000021918 USD per GiB-hour (about 0.016 USD per GiB-month) | After 90 days unmodified; first 10 GiB free |
| Storage Write API (gRPC) ingestion | 0.025 USD per GiB | First 2 TiB a month free |
| Storage Write API (REST), formerly insertAll | 0.01 USD per 200 MiB | 1 KB minimum per row |
| Batch loading from Cloud Storage | No charge | Shared slot pool, no capacity guarantee |
Illustrative example: portfolio of 40 buildings after one year (us-central1 list prices checked 27 October 2026)
- Every volume below is an illustrative assumption for this example, not a typical or recommended figure. Your cost depends on point count, reading interval, row size, retention, how many queries run and what they scan, the region and the pricing model; the arithmetic was run on PostgreSQL 18.6 (PGlite).
- 100,000 points, one reading a minute, 38 logical bytes per row (the table on this page): about 5.1 GiB a day.
- Daily partitions: the last 90 days are active storage (about 459 GiB), the previous 275 days are long-term (about 1,402 GiB).
- Ingestion through the Storage Write API (gRPC) at about 153 GiB a month, assumed to stay inside the 2 TiB free allowance; check the bytes your client actually sends.
- 300 dashboard and report queries a month, each reading three columns over 7 days (about 22.5 GiB) before clustering: about 6.6 TiB processed.
| Item | Basis | Estimated per month |
|---|---|---|
| Active logical storage | 449 GiB billable x 0.000031507 x 730 hours | 10.32 USD |
| Long-term logical storage | 1,392 GiB billable x 0.000021918 x 730 hours | 22.26 USD |
| Ingestion (Storage Write API, gRPC) | Within the 2 TiB free allowance | 0.00 USD |
| On-demand queries | 5.6 TiB billable x 6.25 USD | 35.00 USD |
| Estimated total | About 67.6 USD a month at us-central1 list prices on 27 October 2026, for these illustrative assumptions only: before clustering reduces bytes scanned, and excluding Pub/Sub, Cloud Storage, network and any BI tool costs. Not a quote or a bill. |
Worked examples are estimates calculated from the list prices above; they are not quotes or measured bills.
Frequently asked questions
Is BigQuery a time-series database?
No. It is a general-purpose analytical warehouse that can serve time-series analysis when tables are partitioned by time and clustered by device or point, and it has time series functions such as TIMESTAMP_BUCKET and GAP_FILL. It is not designed for low-latency lookups of the latest reading.
Should I partition IoT data by hour or by day?
Usually by day. A table can have up to 10,000 partitions, so hourly partitions only cover about 14 months. Google suggests hourly partitioning for high volumes over short date ranges and monthly or yearly for small daily volumes.
Can I stream sensor readings straight into BigQuery?
Yes, through the Storage Write API or a Pub/Sub BigQuery subscription; streamed rows are queryable immediately. Both default to at-least-once delivery, so deduplicate on point and timestamp downstream. Streaming is not available in the free tier.
Is loading data into BigQuery free?
Batch loading from Cloud Storage or local files is not charged by default and uses a shared slot pool. Streaming ingestion is charged: on 27 October 2026 the Storage Write API (gRPC) listed 0.025 USD per GiB in us-central1 after the first 2 TiB a month.
How do I stop a query scanning years of readings by accident?
Set require_partition_filter on the table, set maximum bytes billed on queries, and use custom daily query quotas for on-demand billing.
Sources
- BigQuery docs: Introduction to partitioned tables
- BigQuery docs: Introduction to clustered tables
- BigQuery docs: Manage partitioned tables
- BigQuery docs: Query partitioned tables
- BigQuery quotas and limits
- BigQuery docs: Storage Write API (gRPC)
- BigQuery docs: Storage Write API (REST)
- Pub/Sub docs: BigQuery subscriptions
- BigQuery pricing
- GoogleSQL data types (data type sizes)
- GoogleSQL time series functions
- BigQuery docs: Materialized views
- BigQuery ML: ML.DETECT_ANOMALIES
- BigQuery docs: Best practices for controlling costs
Checked 27 October 2026.
How we research these articles: our editorial method. Research-based from official documentation; SQL examples are run on a local database where the page says so. CodeWithSQL earns nothing from the vendors mentioned.