Uploading offline conversions from BigQuery to Google Ads — without the CSV
Google Ads optimises toward the conversions you report. If the ones that matter close in a CRM three weeks later, the bidding is being tuned on a proxy. Offline conversion upload fixes that, and the version worth having reads the rows from a query rather than a file somebody has to produce.
Why this is the highest-leverage integration for lead gen
Smart Bidding is very good at getting more of what you tell it to get. Tell it to get form fills and it will find you the cheapest form fills, including the ones that never answer the phone. Tell it which leads became customers and the same machinery optimises toward revenue instead.
The gap between those two outcomes is usually much larger than anything you would achieve by tuning bids by hand — which is why this is worth building before most of the optimisation work people do instead.
The pipeline, with nothing in the middle
The conventional shape is: query the warehouse, export a CSV, put it somewhere, have a job pick it up, upload. That adds a writable bucket and a scheduler to a pipeline that otherwise needs neither, and every one of those steps is a place for it to quietly stop working.
Taking the rows directly from the previous step's output removes all of it. A `SELECT` that produces one row per closed deal, followed by an upload — two steps, no file, nothing to clean up.
- 1Write the query: one row per conversion, with a click id or a hashed identifier, a conversion time, a value and a currency.
- 2Validate first. The preview reports how many rows survive normalization and why the rest did not, before anything is sent.
- 3Upload, with the conversion action named explicitly.
- 4Put it on a schedule — nightly is usually right, since the warehouse is where the lag already lives.
The rules that decide whether a row counts
- A row with neither a click id nor a hashed identifier can never be attributed. Google accepts it and reports nothing — the worst possible failure, because it looks like success. Those rows should be counted and dropped rather than sent.
- Hashed identifiers make it an Enhanced Conversion for Leads, which the account has to be enabled for separately. A click id needs nothing extra. One such row fails the whole batch, so a good refusal names which rows they are.
- `eventSource` is required, despite the API's own discovery document marking it optional. It should be refused rather than defaulted: stamping a warehouse batch as WEB would mislabel offline data in a way no downstream report reveals.
- A conversion time that cannot be parsed must be refused, not coerced. A timestamp silently set to 1970 is worse than a rejected row.
- Timestamps arrive in four shapes — RFC3339, epoch seconds, epoch millis, and BigQuery's own `2026-09-15 10:00:00 UTC` format. A pipeline that only accepts one of them creates work for no reason.
Three things that will waste an afternoon otherwise
- "Resource not found" on a conversion action that plainly exists is usually propagation, not a wrong id. A newly created action was measured refusing a request for around 20 minutes, then accepting a byte-identical one. Wait rather than debug.
- The first row is not the schema. A batch whose first row lacks an `email` key can silently drop the rows that have one, if the column list is taken from row zero. A warehouse SELECT is uniform enough that this hides for months.
- A future timestamp rejects the entire batch, not the offending row. Filter in the query rather than discovering it in the response.
What good looks like afterwards
- Target CPA set against closed revenue, not form fills.
- Campaigns ranked by contribution, which usually reorders them substantially.
- A nightly upload you do not think about, with a failure that reaches you rather than a silence you discover a month later.
- A matching Customer Match audience built from the same warehouse, for exclusion or for lookalikes.
Frequently asked questions
- Can I upload offline conversions to Google Ads from BigQuery without a CSV?
- Yes. The rows can come straight from a query's own output, so a SELECT followed by an upload is two steps with no file, no bucket and no export job in between.
- Why are some of my uploaded conversions never attributed?
- A row with neither a click id nor a hashed identifier cannot be attributed to anything. Google accepts it and reports nothing, so it looks like a successful upload — which is why those rows should be counted and dropped before sending rather than after.
- What is an Enhanced Conversion for Leads?
- A conversion keyed by a hashed identifier such as an email rather than by a click id. The account must be enabled for it separately, and a single such row will fail an entire batch if it is not.
- Why does my conversion action return "Resource not found"?
- Usually propagation rather than a wrong id. A newly created conversion action was measured refusing uploads for about 20 minutes before accepting a byte-identical request.
- Does uploading offline conversions actually improve performance?
- It changes what Smart Bidding optimises toward — from the proxy you can measure instantly to the outcome you actually want. For lead generation that gap is usually larger than anything bid tuning achieves.