What you need, and what the queries read
Every query here reads metadata your warehouse already keeps about the queries it ran: who ran them, how much they scanned and how long they took. None of them reads your tables, and they scan little. They run in your own account, nothing is sent anywhere, and the link they produce is one you open yourself. The estimator keeps its inputs in the part of the URL after the #, which browsers do not send to a server.
What each platform needs is different, and so is how far back the history goes.
- BigQuery: bigquery.jobs.listAll on the project to read INFORMATION_SCHEMA.JOBS_BY_PROJECT (BigQuery Resource Viewer and BigQuery User have it), which keeps 180 days, and BigQuery Metadata Viewer for INFORMATION_SCHEMA.TABLE_STORAGE. Change region-us to your datasets’ region.
- Snowflake: a role with IMPORTED PRIVILEGES on the SNOWFLAKE database, or its database roles GOVERNANCE_VIEWER (for QUERY_HISTORY) and USAGE_VIEWER (for the metering and storage views). ACCOUNT_USAGE lags: up to 45 minutes for QUERY_HISTORY and up to three hours for the metering and storage views. Running the queries uses a warehouse for a few seconds.
- Redshift Serverless: the SYS_ monitoring views. A regular user sees only their own queries, so run the first query as a superuser or a user granted the sys:monitor role. SYS_SERVERLESS_USAGE, which the second query reads, is visible to superusers only. Both keep about seven days.
Sort your queries into jobs, dashboards and ad hoc
The estimator prices three kinds of work differently, so the one judgement call is telling them apart. Each query starts with a short block where you list the identities behind each kind: the service accounts or users your scheduler runs as (dbt, Airflow, Dataform, scheduled queries), and the ones your BI tool queries as. Everything else counts as ad hoc.
If you already tag queries, with BigQuery job labels or a Snowflake query_tag, classify on those instead: replace the identity test in the CASE expression with one on the tag. If everything runs as one shared account, this is the first thing to fix, because no tool can attribute cost to work it cannot tell apart. The spend guide argues for that attribution step in more detail.
Dashboards are counted as recurring query texts. A BI query text that runs at least every working day is one refreshing tile, its refresh interval is the median gap between runs, and the GB per refresh is set so that tiles × refreshes a day × GB per refresh equals what your BI tool actually scanned. If your BI tool writes literal dates or ids into each query, the same tile can look like many different texts. Then read the refresh interval from your BI tool and set it by hand.
BigQuery
The first query reads 30 days of INFORMATION_SCHEMA.JOBS_BY_PROJECT and INFORMATION_SCHEMA.TABLE_STORAGE. It counts bytes billed rather than bytes processed, so cache hits, which bill nothing, count as zero, and it leaves out the parent job of each script, which repeats the bytes of the statements inside it. BigQuery bills scanned bytes, not time, so there is no scan speed to measure here.
The second prices last calendar month’s billed bytes at the on-demand list rate of $6.25 per TiB less the free TiB, plus storage as it stands today at the logical-storage list rates. Both cover one project. If you are on capacity pricing through BigQuery editions, the query cost line does not apply to you.
-- Your BigQuery workload as warehouse cost estimator inputs.
-- Reads job metadata only. Change the region, then list who runs what.
DECLARE days INT64 DEFAULT 30;
-- Service accounts that run scheduled transforms (dbt, Airflow, Dataform, scheduled queries).
DECLARE scheduler_accounts ARRAY<STRING> DEFAULT ['dbt@your-project.iam.gserviceaccount.com'];
-- Service accounts your BI tool queries as (Looker, Looker Studio, Tableau, Metabase...).
DECLARE bi_accounts ARRAY<STRING> DEFAULT ['looker@your-project.iam.gserviceaccount.com'];
-- Run the whole script (BigQuery's Run does by default), not a selection.
WITH jobs AS (
SELECT
creation_time,
total_bytes_billed / POW(1024, 3) AS gb,
query_info.query_hashes.normalized_literals AS query_hash,
CASE
-- If you label jobs, test the label instead, e.g.
-- WHEN EXISTS (SELECT 1 FROM UNNEST(labels) AS l WHERE l.key = 'dbt_invocation_id') THEN 'job'
WHEN user_email IN UNNEST(scheduler_accounts) THEN 'job'
WHEN user_email IN UNNEST(bi_accounts) THEN 'bi'
ELSE 'adhoc'
END AS kind
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL days DAY)
AND job_type = 'QUERY'
AND state = 'DONE'
AND error_result IS NULL
-- A script's parent job repeats the bytes of the statements inside it.
AND IFNULL(statement_type, '') != 'SCRIPT'
),
-- Each BI query text that recurs is one refreshing tile. Its refresh interval
-- is the median gap between its runs.
bi_runs AS (
SELECT
query_hash,
TIMESTAMP_DIFF(creation_time, LAG(creation_time) OVER (PARTITION BY query_hash ORDER BY creation_time), MINUTE) AS gap
FROM jobs
WHERE kind = 'bi' AND query_hash IS NOT NULL
),
bi AS (
SELECT
COUNT(*) AS tiles,
APPROX_QUANTILES(median_gap, 2)[OFFSET(1)] AS refresh
FROM (
SELECT query_hash, APPROX_QUANTILES(gap, 2)[OFFSET(1)] AS median_gap
FROM bi_runs
WHERE gap > 0
GROUP BY query_hash
HAVING COUNT(*) >= days * 5 / 7 - 1 -- runs at least every working day
)
),
storage AS (
SELECT SUM(total_logical_bytes) / POW(1024, 3) AS gb
FROM `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE
WHERE NOT deleted
),
workload AS (
SELECT
(SELECT gb FROM storage) AS size_gb,
COUNTIF(kind = 'job') / days AS jobs,
SUM(IF(kind = 'job', gb, 0)) / days AS job_gb_per_day,
(SELECT tiles FROM bi) AS tiles,
IFNULL((SELECT refresh FROM bi), 60) AS refresh,
SUM(IF(kind = 'bi', gb, 0)) / days AS bi_gb_per_day,
COUNTIF(kind = 'adhoc') / (days * 5 / 7) AS adhoc,
SUM(IF(kind = 'adhoc', gb, 0)) / (days * 5 / 7) AS adhoc_gb_per_workday
FROM jobs
),
-- The estimator takes whole counts. Round them, never below 1 when there is
-- work, and spread the measured GB over the rounded count so the total holds.
-- With BI work but no tile that recurs, count it as one tile.
inputs AS (
SELECT
size_gb,
jobs,
IF(jobs > 0, GREATEST(ROUND(jobs), 1), 0) AS jobs_n,
job_gb_per_day,
IF(tiles = 0 AND bi_gb_per_day > 0, 1, tiles) AS dashboards,
GREATEST(refresh, 1) AS refresh,
bi_gb_per_day,
adhoc,
IF(adhoc > 0, GREATEST(ROUND(adhoc), 1), 0) AS adhoc_n,
adhoc_gb_per_workday
FROM workload
)
SELECT
ROUND(size_gb, 1) AS size_gb,
ROUND(jobs, 1) AS jobs_per_day,
ROUND(SAFE_DIVIDE(job_gb_per_day, jobs), 2) AS gb_per_job,
dashboards,
refresh AS refresh_minutes,
-- GB per tile refresh, so tiles x refreshes a day x this = what BI really scanned a day.
ROUND(SAFE_DIVIDE(bi_gb_per_day, dashboards * 1440 / refresh), 4) AS gb_per_refresh,
ROUND(adhoc, 1) AS adhoc_per_workday,
ROUND(SAFE_DIVIDE(adhoc_gb_per_workday, adhoc), 2) AS gb_per_adhoc,
CONCAT(
'https://lp-data.cloud/tools/warehouse-cost-estimator',
'#size=', FORMAT('%.1f', IFNULL(size_gb, 0)), '&sizeUnit=GB',
'&jobs=', FORMAT('%.0f', jobs_n),
'&jobGb=', FORMAT('%.2f', IFNULL(SAFE_DIVIDE(job_gb_per_day, jobs_n), 0)),
'&dashboards=', FORMAT('%d', IFNULL(dashboards, 0)),
'&refresh=', FORMAT('%d', refresh),
'&dashGb=', FORMAT('%.4f', IFNULL(SAFE_DIVIDE(bi_gb_per_day, dashboards * 1440 / refresh), 0)),
'&adhoc=', FORMAT('%.0f', adhoc_n),
'&adhocGb=', FORMAT('%.2f', IFNULL(SAFE_DIVIDE(adhoc_gb_per_workday, adhoc_n), 0)),
'&via=sql-bigquery'
) AS estimator_url
FROM inputs;-- What last month's BigQuery on-demand queries and today's storage cost at list price.
-- Rates from https://lp-data.cloud/tools/warehouse-cost-estimator (checked 2026-10-11).
-- Scope: this project only. The free tier is per billing account, so it is
-- applied once here; with several projects, sum them before subtracting it.
WITH month AS (
SELECT SUM(total_bytes_billed) / POW(1024, 4) AS tib_billed
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP(DATE_TRUNC(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH), MONTH))
AND creation_time < TIMESTAMP(DATE_TRUNC(CURRENT_DATE(), MONTH))
AND job_type = 'QUERY'
AND IFNULL(statement_type, '') != 'SCRIPT'
),
storage AS (
SELECT
SUM(active_logical_bytes) / POW(1024, 3) AS active_gib,
SUM(long_term_logical_bytes) / POW(1024, 3) AS long_term_gib
FROM `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE
WHERE NOT deleted
)
SELECT
ROUND(tib_billed, 3) AS tib_billed_last_month,
ROUND(GREATEST(tib_billed - 1, 0) * 6.25, 2) AS query_usd,
-- Logical storage at the us-central1 list rate; the free 10 GiB comes off the active part.
ROUND(GREATEST(active_gib - 10, 0) * 0.023 + long_term_gib * 0.016, 2) AS storage_usd_per_month
FROM month, storage;Snowflake
This is where the query earns its keep. The comparison and the estimator assume an X-Small warehouse scans between 3 and 8 GB a minute, and most of their Snowflake range comes from that assumption. The first query measures it instead: for every query that scanned at least 1 GB, GB scanned per minute of execution time, divided by the warehouse size’s credits an hour so that every size is expressed as X-Small. The 25th and 75th percentiles become the estimator’s slowest and fastest scan speed.
It also returns your most-used warehouse size. The SET lines at the top are separate statements, so in Snowsight use Run All rather than Run, which runs only the statement under the cursor. Two inputs it cannot read from history: auto-suspend, which SHOW WAREHOUSES lists for each warehouse, and your edition. Set both in the estimator’s assumptions after the link opens.
A synthetic example of the result, not real data: size_gb 812.4, jobs_per_day 37, gb_per_job 4.2, dashboards 12, refresh_minutes 60, gb_per_refresh 0.15, adhoc_per_workday 44, gb_per_adhoc 1.8, most_used_size Small, xs_gb_per_minute_p25 5.1, xs_gb_per_minute_p75 11.3, and an estimator_url that ends in #size=812.4&sizeUnit=GB&jobs=37 … &speedLow=5.10&speedHigh=11.30&via=sql-snowflake.
The second query totals last calendar month’s credits per warehouse from WAREHOUSE_METERING_HISTORY and prices compute at a credit price you set, defaulting to the Enterprise list price. Cloud services credits are shown but not priced, because they are billed only above 10% of daily compute.
-- Your Snowflake workload as warehouse cost estimator inputs, including your
-- measured scan speed. Reads ACCOUNT_USAGE metadata only.
-- Fill in who runs what: user names, roles, or a query_tag pattern if you tag
-- queries. In Snowsight use Run All, or the SET lines are not run first.
SET days = 30;
SET scheduler_users = 'DBT_USER,AIRFLOW_USER'; -- scheduled transforms
SET scheduler_roles = 'TRANSFORMER';
SET scheduler_tag = '%dbt%'; -- query_tag pattern; '' if you do not tag
SET bi_users = 'LOOKER_USER,TABLEAU_USER'; -- your BI tool's user(s)
SET bi_roles = 'REPORTER';
WITH q AS (
SELECT
start_time,
query_parameterized_hash,
warehouse_size,
bytes_scanned / POWER(1024, 3) AS gb,
execution_time / 60000 AS minutes,
CASE
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('XSMALL') THEN 1
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('SMALL') THEN 2
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('MEDIUM') THEN 4
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('LARGE') THEN 8
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('XLARGE') THEN 16
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('2XLARGE', 'XXLARGE', 'X2LARGE') THEN 32
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('3XLARGE', 'XXXLARGE', 'X3LARGE') THEN 64
WHEN UPPER(REPLACE(warehouse_size, '-', '')) IN ('4XLARGE', 'X4LARGE') THEN 128
END AS credits_per_hour,
CASE
WHEN ARRAY_CONTAINS(user_name::VARIANT, SPLIT($scheduler_users, ','))
OR ARRAY_CONTAINS(role_name::VARIANT, SPLIT($scheduler_roles, ','))
OR query_tag ILIKE NULLIF($scheduler_tag, '') THEN 'job'
WHEN ARRAY_CONTAINS(user_name::VARIANT, SPLIT($bi_users, ','))
OR ARRAY_CONTAINS(role_name::VARIANT, SPLIT($bi_roles, ',')) THEN 'bi'
ELSE 'adhoc'
END AS kind
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE start_time >= DATEADD(day, -$days, CURRENT_TIMESTAMP())
AND execution_status = 'SUCCESS'
AND warehouse_size IS NOT NULL
-- Queries that read data. Leaves out SHOW, DESCRIBE, transaction control
-- and result-cache hits, which do not run the warehouse for scanning.
AND bytes_scanned > 0
),
-- GB a minute of execution, divided by the size's credits an hour so every
-- query is expressed as X-Small speed. Only queries that scanned at least 1 GB,
-- so tiny metadata queries do not drag the figure down.
speed AS (
SELECT
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY gb / minutes / credits_per_hour) AS speed_low,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY gb / minutes / credits_per_hour) AS speed_high
FROM q
WHERE gb >= 1 AND minutes > 0
),
bi_runs AS (
SELECT
query_parameterized_hash,
DATEDIFF(minute, LAG(start_time) OVER (PARTITION BY query_parameterized_hash ORDER BY start_time), start_time) AS gap
FROM q
WHERE kind = 'bi' AND query_parameterized_hash IS NOT NULL
),
bi AS (
SELECT COUNT(*) AS tiles, MEDIAN(median_gap) AS refresh
FROM (
SELECT query_parameterized_hash, MEDIAN(gap) AS median_gap
FROM bi_runs
WHERE gap > 0
GROUP BY query_parameterized_hash
HAVING COUNT(*) >= $days * 5 / 7 - 1 -- runs at least every working day
)
),
storage AS (
-- Database bytes including Time Travel; Fail-safe is left out, because the
-- estimator uses the same size for BigQuery and Redshift.
SELECT SUM(average_database_bytes) / POWER(1024, 3) AS gb
FROM SNOWFLAKE.ACCOUNT_USAGE.DATABASE_STORAGE_USAGE_HISTORY
WHERE usage_date = (SELECT MAX(usage_date) FROM SNOWFLAKE.ACCOUNT_USAGE.DATABASE_STORAGE_USAGE_HISTORY)
),
workload AS (
SELECT
(SELECT gb FROM storage) AS size_gb,
COUNT_IF(kind = 'job') / $days AS jobs,
SUM(IFF(kind = 'job', gb, 0)) / $days AS job_gb_per_day,
(SELECT tiles FROM bi) AS tiles,
COALESCE((SELECT ROUND(refresh) FROM bi), 60) AS refresh,
SUM(IFF(kind = 'bi', gb, 0)) / $days AS bi_gb_per_day,
COUNT_IF(kind = 'adhoc') / ($days * 5 / 7) AS adhoc,
SUM(IFF(kind = 'adhoc', gb, 0)) / ($days * 5 / 7) AS adhoc_gb_per_workday,
MODE(warehouse_size) AS wh_size,
(SELECT speed_low FROM speed) AS speed_low,
(SELECT speed_high FROM speed) AS speed_high
FROM q
),
-- The estimator takes whole counts. Round them, never below 1 when there is
-- work, and spread the measured GB over the rounded count so the total holds.
-- With BI work but no tile that recurs, count it as one tile.
inputs AS (
SELECT
*,
IFF(jobs > 0, GREATEST(ROUND(jobs), 1), 0) AS jobs_n,
IFF(tiles = 0 AND bi_gb_per_day > 0, 1, tiles) AS dashboards,
IFF(adhoc > 0, GREATEST(ROUND(adhoc), 1), 0) AS adhoc_n
FROM workload
)
SELECT
ROUND(size_gb, 1) AS size_gb,
ROUND(jobs, 1) AS jobs_per_day,
ROUND(DIV0(job_gb_per_day, jobs), 2) AS gb_per_job,
dashboards,
refresh AS refresh_minutes,
-- GB per tile refresh, so tiles x refreshes a day x this = what BI really scanned a day.
ROUND(DIV0(bi_gb_per_day, dashboards * 1440 / GREATEST(refresh, 1)), 4) AS gb_per_refresh,
ROUND(adhoc, 1) AS adhoc_per_workday,
ROUND(DIV0(adhoc_gb_per_workday, adhoc), 2) AS gb_per_adhoc,
wh_size AS most_used_size,
ROUND(speed_low, 2) AS xs_gb_per_minute_p25,
ROUND(speed_high, 2) AS xs_gb_per_minute_p75,
'https://lp-data.cloud/tools/warehouse-cost-estimator'
|| '#size=' || TO_VARCHAR(COALESCE(size_gb, 0)::NUMBER(38, 1)) || '&sizeUnit=GB'
|| '&jobs=' || TO_VARCHAR(jobs_n::NUMBER(38, 0))
|| '&jobGb=' || TO_VARCHAR(DIV0(job_gb_per_day, jobs_n)::NUMBER(38, 2))
|| '&dashboards=' || TO_VARCHAR(COALESCE(dashboards, 0))
|| '&refresh=' || TO_VARCHAR(GREATEST(refresh, 1)::NUMBER(38, 0))
|| '&dashGb=' || TO_VARCHAR(COALESCE(DIV0(bi_gb_per_day, dashboards * 1440 / GREATEST(refresh, 1)), 0)::NUMBER(38, 4))
|| '&adhoc=' || TO_VARCHAR(adhoc_n::NUMBER(38, 0))
|| '&adhocGb=' || TO_VARCHAR(DIV0(adhoc_gb_per_workday, adhoc_n)::NUMBER(38, 2))
-- Left out when the size is not one the estimator models (5XL, 6XL).
|| COALESCE('&whSize=' || CASE
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('XSMALL') THEN 'XS'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('SMALL') THEN 'S'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('MEDIUM') THEN 'M'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('LARGE') THEN 'L'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('XLARGE') THEN 'XL'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('2XLARGE', 'XXLARGE', 'X2LARGE') THEN '2XL'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('3XLARGE', 'XXXLARGE', 'X3LARGE') THEN '3XL'
WHEN UPPER(REPLACE(wh_size, '-', '')) IN ('4XLARGE', 'X4LARGE') THEN '4XL'
END, '')
|| COALESCE('&speedLow=' || TO_VARCHAR(GREATEST(speed_low, 0.1)::NUMBER(38, 2))
|| '&speedHigh=' || TO_VARCHAR(GREATEST(speed_high, 0.1)::NUMBER(38, 2)), '')
|| '&via=sql-snowflake' AS estimator_url
FROM inputs;-- What your warehouses used last month, priced at a credit price you set.
-- List prices (checked 2026-10-14): Standard $2, Enterprise $3,
-- Business Critical $4 per credit, on demand in AWS US East. Your contract may differ.
SET credit_price = 3;
SELECT
warehouse_name,
ROUND(SUM(credits_used_compute), 1) AS compute_credits,
ROUND(SUM(credits_used_cloud_services), 1) AS cloud_services_credits,
-- Compute only: cloud services are billed only above 10% of daily compute.
ROUND(SUM(credits_used_compute) * $credit_price, 2) AS compute_usd
FROM SNOWFLAKE.ACCOUNT_USAGE.WAREHOUSE_METERING_HISTORY
WHERE start_time >= DATE_TRUNC(month, DATEADD(month, -1, CURRENT_DATE()))
AND start_time < DATE_TRUNC(month, CURRENT_DATE())
GROUP BY warehouse_name
ORDER BY compute_usd DESC;Redshift Serverless
Redshift is the platform where query history tells you least about cost, and it is better to say so than to guess. SYS_QUERY_HISTORY records when each query ran, who ran it and how long it took, but no bytes scanned. SYS_QUERY_DETAIL has byte counts, but mixed across several levels of each query plan with no documented figure for what was read from storage. So there is no reliable GB per query, and without it no scan speed. The first query returns counts only: jobs a day, recurring dashboard queries and their refresh interval, and ad-hoc queries a working day. GB per run stays at the estimator’s defaults until you set it, and the scan speed stays at its 3 to 8 GB a minute assumption.
The SYS_ views keep about seven days of history, so both queries read the last week rather than a month. For a full month, copy SYS_QUERY_HISTORY into a table of your own on a daily schedule and point the query at that.
The second query is the more useful one here. SYS_SERVERLESS_USAGE records the RPU-seconds you were charged each minute, so it prices the last seven days at $0.375 per RPU-hour and scales that to a month, and reads current storage. Redshift does not expose the workgroup’s base RPUs in SQL; read them in the console and set them in the estimator.
-- Your Redshift Serverless workload as warehouse cost estimator inputs: counts only.
-- SYS_ views keep about seven days of history, so this reads the last week.
-- Run as a superuser or a user with the sys:monitor role, or you only see your own queries.
-- Fill in who runs what in the CASE below.
WITH q AS (
SELECT
start_time,
generic_query_hash,
CASE
WHEN TRIM(username) IN ('dbt_user', 'airflow_user') THEN 'job' -- scheduled transforms
WHEN TRIM(username) IN ('looker_user') THEN 'bi' -- your BI tool's user(s)
ELSE 'adhoc'
END AS kind
FROM sys_query_history
WHERE start_time >= DATEADD(day, -7, GETDATE())
AND status = 'success'
-- SET, SHOW and catalog lookups from drivers and BI tools are not work.
AND query_type NOT IN ('UTILITY', 'DDL')
),
bi_runs AS (
SELECT
generic_query_hash,
DATEDIFF(minute, LAG(start_time) OVER (PARTITION BY generic_query_hash ORDER BY start_time), start_time) AS gap
FROM q
WHERE kind = 'bi' AND generic_query_hash IS NOT NULL
),
bi AS (
SELECT COUNT(*) AS tiles, MEDIAN(median_gap) AS refresh
FROM (
SELECT generic_query_hash, MEDIAN(gap) AS median_gap
FROM bi_runs
WHERE gap > 0
GROUP BY generic_query_hash
HAVING COUNT(*) >= 4 -- runs at least every working day (5 runs, 4 gaps)
) AS tiles
),
workload AS (
SELECT
COUNT(CASE WHEN kind = 'job' THEN 1 END) / 7.0 AS jobs,
COUNT(CASE WHEN kind = 'adhoc' THEN 1 END) / 5.0 AS adhoc, -- 7 days, 5 working days
COUNT(CASE WHEN kind = 'bi' THEN 1 END) AS bi_queries
FROM q
)
SELECT
ROUND(w.jobs, 1) AS jobs_per_day,
-- With BI queries but no tile that recurs, count them as one tile.
CASE WHEN b.tiles = 0 AND w.bi_queries > 0 THEN 1 ELSE b.tiles END AS dashboards,
COALESCE(ROUND(b.refresh), 60) AS refresh_minutes,
ROUND(w.adhoc, 1) AS adhoc_per_workday,
'https://lp-data.cloud/tools/warehouse-cost-estimator'
-- Whole counts, never below 1 when there is work.
|| '#jobs=' || CASE WHEN w.jobs > 0 THEN GREATEST(ROUND(w.jobs), 1) ELSE 0 END::BIGINT::VARCHAR
|| '&dashboards=' || (CASE WHEN b.tiles = 0 AND w.bi_queries > 0 THEN 1 ELSE b.tiles END)::VARCHAR
|| '&refresh=' || GREATEST(COALESCE(ROUND(b.refresh), 60), 1)::BIGINT::VARCHAR
|| '&adhoc=' || CASE WHEN w.adhoc > 0 THEN GREATEST(ROUND(w.adhoc), 1) ELSE 0 END::BIGINT::VARCHAR
|| '&via=sql-redshift' AS estimator_url
FROM workload AS w CROSS JOIN bi AS b;-- What the last seven days of Redshift Serverless compute cost at list price,
-- and the same rate over a month. SYS_SERVERLESS_USAGE keeps seven days and is
-- visible to superusers only.
-- List price (checked 2026-10-11): $0.375 per RPU-hour, $0.024 per GB-month storage, US East.
SELECT
ROUND(SUM(charged_seconds) / 3600.0, 1) AS rpu_hours_7_days,
ROUND(SUM(charged_seconds) / 3600.0 * 0.375, 2) AS compute_usd_7_days,
ROUND(SUM(charged_seconds) / 3600.0 * 0.375 * (365.0 / 12) / 7, 2) AS compute_usd_per_month_at_this_rate,
-- data_storage is in MB.
ROUND(MAX(CASE WHEN end_time = latest THEN data_storage END) / 1024.0, 1) AS storage_gb,
ROUND(MAX(CASE WHEN end_time = latest THEN data_storage END) / 1024.0 * 0.024, 2) AS storage_usd_per_month
FROM sys_serverless_usage
CROSS JOIN (SELECT MAX(end_time) AS latest FROM sys_serverless_usage) AS l
WHERE start_time >= DATEADD(day, -7, GETDATE());When the estimate and the bill disagree
Run both queries for a platform and compare. A gap under about a fifth means the inputs are about right. A larger one usually means the sorting in the second section needs another pass, or one of the following dominates your account.
- Cache hits and minimums. BigQuery bills nothing for a cached result and at least 10 MB for every query that does scan, so many tiny queries cost more than their bytes suggest.
- Snowflake cloud services. Above 10% of daily compute they are billed, and the estimator leaves them out.
- Concurrency. Several queries sharing one running warehouse each look slower than the warehouse is, so the measured scan speed is pessimistic for busy warehouses and the high end of the range overstates the bill.
- Multi-cluster warehouses and several warehouses of different sizes. The estimator models one warehouse at one size.
- Reserved or committed capacity: BigQuery editions, Snowflake capacity contracts, negotiated discounts. The estimator prices list on demand.
- BI tools that cache. Some serve repeated views from their own cache, so fewer refreshes reach the warehouse than the schedule suggests. The query counts only what reached it, which is what is billed.