How I Accidentally Cost a Client €1,700 in BigQuery
A real BigQuery cost post-mortem: how broad queries and a raw-table dashboard produced a €1,700 bill, plus six guardrails to prevent it.
10 min readBranislav Mateas
Copy post as Markdown
On this page
I did not set out to create a €1,700 BigQuery case study. I helped build reporting that worked until the invoice proved it did not work safely. This is the full story behind my MeasureCamp Czechia talk, including every lesson from the slides.
The amount is approximate, and the client is intentionally anonymous. No client details, project identifiers, or identifiable screenshots appear here. The important number is not the exact invoice. It is how quickly a series of small, reasonable-looking actions accumulated.
This is not a guru story. It is a beginner post-mortem about on-demand BigQuery pricing, simple SQL, reporting architecture, and the guardrails I wish I had used from the start.
The bill was approximately €1,700 in unexpected BigQuery charges. It did not come from one spectacularly bad query. It came from a pattern:
A broad query worked.
It was run repeatedly while the analysis was being developed.
A dashboard multiplied the same expensive pattern.
The invoice finally made the pattern visible.
The dashboard did what it was asked to do, many times. Broad exploration, repeated execution, and a reporting layer pointed at the same large raw source turned individually small-looking choices into a material bill.
Imagine a library. You ask a librarian to return ten useful lines. If the librarian must read eight shelves to find those lines, the work is eight shelves of reading, not ten lines of output. BigQuery follows the same basic logic: a tiny result can still require a huge scan.
Result size is not scan size. A query can return ten rows while reading the selected columns across a very large table.
BigQuery also has storage costs and capacity-based compute pricing. Those are real, but they are outside this beginner story. This incident concerns on-demand query pricing and the amount of data read.
The dangerous misconception: LIMIT limits the answer
This is the beginner trap I fell into. Ten rows feels small, so a query with LIMIT 10 feels safe:
SELECT *
FROM `analytics.large_table`
LIMIT 10;
The query returns ten rows, but LIMIT controls the output after processing. It does not automatically stop BigQuery from reading the selected columns. On a non-clustered table, LIMIT does not reduce the compute cost of SELECT *.
Use LIMIT when you need a smaller result. Do not treat it as a billing guardrail. Before execution, look at the query estimate in the console instead of trusting the size of the expected answer.
Cost driver 1: SELECT * quietly chooses every column
SELECT *
FROM `analytics.large_table`;
The star means every column. In a wide raw analytics table, that may include event dates, user identifiers, event names, device details, geography, traffic source data, nested ecommerce items, parameters, and more than a hundred additional fields.
Because BigQuery stores data by column, asking for every column can make it read far more than the question needs. The query is convenient to write, but the convenience hides its scope.
The incident was not caused by running this pattern once. Repetition was the multiplier: edit, run, inspect, change, run again. Repeated broad scans turned convenience into cost.
Cost driver 2: Looker Studio kept hitting the raw table
The second driver was architectural. Looker Studio was connected directly to a large central table. That source was wide, detailed, and covered a long date range. Everything was available, which made the connection easy. It also made the source expensive.
A dashboard page can contain a scorecard, a trend, a table, filters, charts, and blended data. Each component may need to ask its own question. If they all query the same large raw table, the reporting layer repeatedly asks a very detailed source to answer relatively simple reporting questions.
Looker Studio is not inherently expensive. The problem was the combination of dashboard query behavior and an oversized source table. Easy connection, expensive source.
The multiplier: one refresh can fan out into many queries
We saw one dashboard refresh. The billing system saw a stream of query jobs. A single interaction could fan out into:
A scorecard query that performs a large raw scan.
A chart query that performs another large raw scan.
A table query that performs another large raw scan.
A filter query that performs another large raw scan.
A blended-data query that performs another large raw scan.
Now multiply that by charts, filters, viewers, date changes, and refreshes. Bytes accumulate quickly when every request goes back to the same oversized source.
The diagnosis came from matching the increase in billing with BigQuery query history and the reporting usage pattern. That is when our mental model changed. Prevention needs both guardrails and feedback, so query history and billing trends should be monitored continuously.
Guardrail 1: ask only for the columns you need
Replace the expensive habit with a better question. Instead of selecting everything:
SELECT *
FROM `analytics.events`;
Name the fields the analysis actually depends on:
SELECT
event_date,
event_name,
user_pseudo_id
FROM `analytics.events`;
Fewer columns read usually means fewer bytes processed. The query is also easier to review because another analyst can see exactly which fields it depends on. This habit will not solve every cost problem, but it is the first habit to build. Start narrow and add fields only when the question needs them.
Guardrail 2: explore with TABLESAMPLE
When you are learning the shape of a large table, checking formats, or looking for example values, you often do not need complete data. TABLESAMPLE can reduce the amount of storage read during that exploration:
SELECT
event_name, user_pseudo_id
FROM `analytics.events`
TABLESAMPLE SYSTEM (1 PERCENT);
Sampling is useful for exploration, but it does not make every analysis statistically valid. BigQuery also does not cache query results that include TABLESAMPLE, so every execution incurs the cost of reading the sampled data. Explore with a sample, then move to a properly filtered production query.
Guardrail 3: read the estimate before Run
The BigQuery editor shows an estimate before execution when it can calculate one. Build a physical habit: your eyes should go to the estimate before they go to the Run button.
Example: “This query will process 2.4 TB.” Pause here before clicking Run.
If the estimate surprises you, check the selected columns, the date range, the source table, the width of a wildcard, and whether a join includes a much larger table than expected.
Estimates are guardrails, not guarantees for every query shape. Google notes that the console estimate is an upper bound and can be higher than the bytes ultimately billed, especially with clustered tables. Even so, an unexpected estimate is a strong reason to stop and inspect. The official guide covers the query validator and dry runs.
The cheapest bad query is the one you do not run.
Guardrail 4: give every query a hard ceiling
Maximum bytes billed is a seat belt for on-demand queries. If BigQuery estimates that the query will exceed the configured ceiling, it refuses to run.
The command above uses 1 GB as an example, not as a universal recommendation. Choose a ceiling appropriate to the task, the source, and the expected result. If the estimate exceeds the threshold, the query fails safely instead of creating an unexpected charge.
You can configure the limit in the BigQuery UI, in an API query job, in the bq command-line tool, or through a client library. In an API job configuration, the concept is query.maximumBytesBilled. In the console, use Query settings, Advanced options, and Maximum bytes billed.
This matters even more when AI agents can generate or execute SQL. A model can produce plausible broad SQL very quickly. A hard billing ceiling turns that mistake into an error rather than an invoice. Pair the ceiling with read-only permissions and human review where appropriate.
Guardrail 5: filter GA4 dates
GA4 standard daily exports commonly use date-sharded tables named events_YYYYMMDD. The wildcard events_* can match many daily tables, so explicitly restrict the date-like suffix:
SELECT
event_date, event_name, user_pseudo_id
FROM `analytics.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260907';
If your own reporting tables are partitioned instead, filter the partitioning column for the same economic reason: let BigQuery prune what it reads.
Guardrail 6: use exactness only when it matters
Exact answers are necessary when every unit matters. They are not necessary for every analytical question. For very large distinct counts, BigQuery provides an approximate alternative:
SELECT
APPROX_COUNT_DISTINCT(user_pseudo_id) AS users
FROM `analytics.events`;
Use exact COUNT(DISTINCT ...) for billing, payouts, contractual reporting, compliance, and small reconciliations. Use approximate distinct counts for trends, dashboards, exploration, and very large inputs when a small statistical difference will not change the decision.
Approximation changes the answer slightly. It does not change the definition of the question.
The better architecture: build reporting tables first
The durable fix was not one optimized query. It was changing the reporting architecture:
Raw GA4 export: keep detailed events as the source of truth.
Dataform or another transformation workflow: run scheduled SQL transformations and tests.
Reporting tables: make them narrow, aggregated, and partitioned around the questions the dashboard actually answers.
Looker Studio: point the dashboard at prepared tables so interactions are fast and controlled.
The economic idea is reuse. Perform the expensive transformation intentionally once, then allow many dashboard interactions to read a much smaller table. Add tests and clear ownership so the controls survive beyond one analyst. Transform once. Serve many times.
Before and after
Before, the system was raw table to dashboard:
Broad SELECT * exploration.
Many dashboard queries hitting the detailed source.
No hard per-query ceiling.
Cost discovered after the fact.
After, the system was prepared tables to dashboard:
Named columns and explicit date filters.
Small reporting tables.
Estimates and maximum-bytes-billed caps.
Cost visible before execution.
The goal is not to blame a person or promise an unsupported percentage saving. The defensible outcome is that query scope became controlled and visible. Cheaper was also clearer, faster, and less fragile.