# BigQuery audit logs explained > BigQuery writes Admin Activity and Data Access audit logs to Cloud Logging, with Data Access logs on by default. Each job writes entries in two formats at once, a sink routes them into BigQuery tables, and INFORMATION_SCHEMA.JOBS answers the cost questions. Source: https://docs.querylab.io/bigquery/audit-logs/ Updated: 2026-10-14 BigQuery audit logs record who did what to your projects: who created a table, granted access, ran a query or read a column. They are Cloud Audit Logs, so they live in Cloud Logging, not in BigQuery, until you route them into a dataset with a sink. For BigQuery, Data Access audit logs are on by default, which is unusual: for other Google Cloud services they are off until you enable them. (The BigQuery Reservation API is an exception and needs `ADMIN_READ` logs turned on.) Audit logs say who and what. For how much a query cost or how many slots it used, you need `INFORMATION_SCHEMA.JOBS`. ## The log types Cloud Audit Logs has four types. For BigQuery you mainly deal with the first two. | Type | What it records | On by default | Where it is stored | | -------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | Admin Activity | Changes to configuration or metadata: create, update or delete datasets, tables, routines, models, and IAM changes | always, can't be disabled or excluded | `_Required` bucket, 400 days, no charge | | Data Access | Reading metadata, and reading or writing user data: jobs, query results, table reads | for BigQuery yes | `_Default` bucket, 30 days by default, billed as ingestion | | System Event | Actions by Google systems, not users | always | `_Required` bucket | | Policy Denied | Requests denied by a security policy such as VPC Service Controls | on by default, can't be disabled (you can exclude them from storage), storage is billed | `_Default` bucket | We checked the default on a project that has no audit configuration in its IAM policy: it still holds BigQuery Data Access entries for every query, with nothing enabled. A single job can write to both logs. In our project, a `CREATE TABLE AS SELECT` wrote an entry in Admin Activity (a `tableCreation` event, permission `bigquery.tables.create`, type `ADMIN_WRITE`) and entries in Data Access (the job itself, and a `tableDataRead` for each table it read). A `GRANT` on a dataset wrote a `datasetChange` entry in Admin Activity with the added member and role in `bindingDeltas`. ## Two formats at the same time BigQuery writes two generations of audit entries. Google calls them the older and newer format: | | Older (`AuditData`) | Newer (`BigQueryAuditMetadata`) | | ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | Resource type | `bigquery_resource` | `bigquery_project` for jobs, `bigquery_dataset` for storage | | Details in | `protoPayload.serviceData` | `protoPayload.metadata` | | Method names | `jobservice.insert`, `jobservice.jobcompleted`, `tableservice.delete` | `google.cloud.bigquery.v2.JobService.InsertJob`, `...TableService.DeleteTable` | | Shape | request and response | events: `jobChange`, `tableCreation`, `tableDataRead` and so on | Google recommends the newer format. The older one is not switched off. We read the latest 400 BigQuery entries from one project and found both side by side: 115 new-format `InsertJob` entries, 64 old-format `jobservice.insert` and 79 old-format `jobservice.jobcompleted`. A typical query job wrote four entries in Data Access: two new (a `jobInsertion` when it starts, a `jobChange` with `after: DONE` when it ends) and two old (`jobservice.insert` or `jobservice.query` at the start, `jobservice.jobcompleted` at the end). Reading the results added a `jobservice.getqueryresults` entry. If you route "all BigQuery logs" you pay to store each job twice. Filter on the new format: ``` protoPayload.metadata."@type"="type.googleapis.com/google.cloud.audit.BigQueryAuditMetadata" ``` That is the filter Google gives in its migration guide. The `jobChange` entry has the data most people want: the SQL, the statement type, start and end times, the reservation, referenced tables, and `queryStats.totalBilledBytes`. ## Which events answer which question | Question | Look for | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Who created, changed or deleted a table or dataset | Admin Activity: `TableService.InsertTable`, `DeleteTable`, `DatasetService.*` (`metadata.tableCreation`, `tableChange`, `datasetChange`) | | Who got access to a dataset | `metadata.datasetChange.bindingDeltas` | | Who ran a query | Data Access: `jobChange` with `after = 'DONE'` and `authenticationInfo.principalEmail` | | Who read a table, and which columns | Data Access: `metadata.tableDataRead` on the table's resource, with its `fields` list | | From which IP and client | `requestMetadata.callerIp` and `callerSuppliedUserAgent` | A `tableDataRead` entry is written in the project that owns the table, and names the columns the job read. That makes it the way to answer "who has read the salary column", which `INFORMATION_SCHEMA.JOBS` can't tell you directly (see [column-level security](https://docs.querylab.io/bigquery/column-level-security/)). ## Route the logs into BigQuery In Logging you can search recent entries, but SQL over months is easier in BigQuery. A **sink** in Cloud Logging routes entries that match a filter to a BigQuery dataset. This is what we ran, in a throwaway dataset: ```sh gcloud logging sinks create bq-audit \ bigquery.googleapis.com/projects/PROJECT/datasets/audit \ --use-partitioned-tables \ --log-filter='protoPayload.metadata."@type"="type.googleapis.com/google.cloud.audit.BigQueryAuditMetadata"' ``` Then grant the sink's writer identity (the command prints it) the BigQuery Data Editor role on the dataset. For example, with SQL: ``GRANT `roles/bigquery.dataEditor` ON SCHEMA audit TO "serviceAccount:..."``. Things we saw and Google documents: - Two tables appeared, named from the log names: `cloudaudit_googleapis_com_activity` and `cloudaudit_googleapis_com_data_access`, each partitioned by day on `timestamp`. Without `--use-partitioned-tables` Google says you get date-sharded tables (`..._YYYYMMDD`), the default. Choose partitioned: it prunes by date and costs less to query. See [partitioning](https://docs.querylab.io/bigquery/partitioning/). - Entries arrive within about a minute. Logging uses the legacy streaming API; a new table can take several minutes. - The payload is mostly JSON. The columns are `protopayload_auditlog` (a record with `methodName`, `resourceName`, `authenticationInfo`, `requestMetadata`, and so on) and `metadataJson`, a single `STRING` column holding the whole `BigQueryAuditMetadata` as JSON. You read it with `JSON_VALUE`, not dotted paths. See [JSON](https://docs.querylab.io/bigquery/json/). - The first entry decides the schema. If a later entry changes a field's type, or adds fields past BigQuery's column limit, the sink writes the failures to an `export_errors` table. - Routing is free, storage isn't. Logging doesn't charge for routing, but you pay BigQuery for the table storage and the streamed rows. Entries that also stay in the `_Default` bucket cost $0.50 per GiB ingested beyond a free 50 GiB per project per month. Here is the query most teams want first, estimated spend per user. The rows are made-up, but follow the shape we saw in real entries, including the entry a job writes when it starts, which the `DONE` filter drops: **billed bytes per user from the sink's metadataJson column** ```sql WITH log AS ( SELECT STRUCT(STRUCT('ana@example.com' AS principalEmail) AS authenticationInfo, '{"jobChange":{"after":"DONE","job":{"jobConfig":{"type":"QUERY"},"jobStats":{"queryStats":{"totalBilledBytes":"10485760","totalProcessedBytes":"1332943"}}}}}' AS metadataJson) AS protopayload_auditlog UNION ALL SELECT STRUCT(STRUCT('ana@example.com'), '{"jobChange":{"after":"DONE","job":{"jobConfig":{"type":"QUERY"},"jobStats":{"queryStats":{"totalBilledBytes":"1099511627776"}}}}}') UNION ALL SELECT STRUCT(STRUCT('raj@example.com'), '{"jobChange":{"after":"DONE","job":{"jobConfig":{"type":"QUERY"},"jobStats":{"queryStats":{"totalBilledBytes":"5497558138880"}}}}}') UNION ALL SELECT STRUCT(STRUCT('raj@example.com'), '{"jobInsertion":{"job":{"jobConfig":{"type":"QUERY"}}}}') ) SELECT protopayload_auditlog.authenticationInfo.principalEmail AS user_email, COUNT(*) AS finished_queries, ROUND(SUM(CAST(JSON_VALUE(protopayload_auditlog.metadataJson, '$.jobChange.job.jobStats.queryStats.totalBilledBytes') AS INT64)) / POW(1024, 4) * 6.25, 2) AS approx_usd FROM log WHERE JSON_VALUE(protopayload_auditlog.metadataJson, '$.jobChange.after') = 'DONE' GROUP BY user_email ORDER BY approx_usd DESC; ``` Result in BigQuery (2 rows): | user_email | finished_queries | approx_usd | | --- | --- | --- | | raj@example.com | 1 | 31.25 | | ana@example.com | 2 | 6.25 | Two details in real data. First, `totalBilledBytes` is left out of the JSON when it is zero. A query we ran over a tiny table billed 0 bytes and its entry had no such field, so the value is `NULL`, not `0`; `SUM` ignores it. Second, Google's migration guide still prices the hourly cost query at $5.00 per TiB, the old on-demand rate. Use the current rate for your edition and location. See [how queries are billed](https://docs.querylab.io/bigquery/how-queries-are-billed/). The `tableDataRead` events give you column-level access reports: **who read which columns of a table** ```sql WITH log AS ( SELECT STRUCT('projects/p/datasets/hr/tables/salaries' AS resourceName, STRUCT('ana@example.com' AS principalEmail) AS authenticationInfo, '{"tableDataRead":{"fields":["employee_id","salary"],"jobName":"projects/p/jobs/j1","reason":"JOB"}}' AS metadataJson) AS protopayload_auditlog UNION ALL SELECT STRUCT('projects/p/datasets/hr/tables/salaries', STRUCT('raj@example.com'), '{"tableDataRead":{"fields":["employee_id"],"jobName":"projects/p/jobs/j2","reason":"JOB"}}') UNION ALL SELECT STRUCT('projects/p/datasets/hr/tables/salaries', STRUCT('raj@example.com'), '{"tableDataRead":{"fields":["employee_id","salary"],"jobName":"projects/p/jobs/j3","reason":"JOB"}}') ) SELECT protopayload_auditlog.authenticationInfo.principalEmail AS user_email, column_name, COUNT(*) AS reads FROM log, UNNEST(JSON_VALUE_ARRAY(protopayload_auditlog.metadataJson, '$.tableDataRead.fields')) AS column_name WHERE protopayload_auditlog.resourceName = 'projects/p/datasets/hr/tables/salaries' GROUP BY user_email, column_name ORDER BY column_name DESC, user_email; ``` Result in BigQuery (4 rows): | user_email | column_name | reads | | --- | --- | --- | | ana@example.com | salary | 1 | | raj@example.com | salary | 1 | | ana@example.com | employee_id | 1 | | raj@example.com | employee_id | 2 | One trap: when the sink dataset is in a project the sink covers, your queries over the audit tables are themselves BigQuery jobs, and they are logged and routed back into the same tables. We found our own `SELECT` statements in the routed `metadataJson`. A dashboard that polls the audit table adds rows to it. Exclude the audit dataset from the sink filter, or route to a different project, and delete test sinks. ## Audit logs or INFORMATION_SCHEMA.JOBS? Google's own guide says audit logs are the definitive record of who did what and where, and `INFORMATION_SCHEMA` is where you analyze workloads. They overlap on jobs and differ on almost everything else: | | Audit logs | `INFORMATION_SCHEMA.JOBS` | | ------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------ | | Question | who did what, when, from where | how much did it cost, how long did it take | | Covers | jobs, plus dataset, table, routine and IAM changes, plus table reads | jobs only | | Bytes, slots, cache hit, stages | partly (billed bytes and reservation in `jobChange`; no slot detail) | yes | | Caller IP and client | yes | no | | Retention | 400 days (Admin Activity), 30 days by default (Data Access), or as long as your sink keeps it | 180 days | | Setup | none to read in Logging; a sink to query in BigQuery | none, query it in place | | Cost | Logging ingestion beyond the free tier, plus BigQuery storage for a sink | normal query cost | Use `INFORMATION_SCHEMA` for bills and slow queries, and audit logs for security questions, access reviews and "who deleted this table". Querylab.io's [query history](https://docs.querylab.io/writing-sql/query-history/) reads `INFORMATION_SCHEMA.JOBS` for you and lists every job in the project, whichever tool ran it, with its cost. See [monitoring jobs and costs](https://docs.querylab.io/bigquery/monitoring-jobs-and-costs/) and [INFORMATION_SCHEMA](https://docs.querylab.io/bigquery/information-schema/). ## Viewing and permissions Logs Viewer sees Admin Activity, Policy Denied and System Event logs. Data Access entries in the `_Default` bucket need Private Logs Viewer. Audit entries can contain SQL text, schema definitions and resource names, so treat access to the sink dataset as sensitive. Each log message is limited to 100,000 bytes, so very long queries are truncated. Google's list of audited methods (`google.cloud.bigquery.v2.*` and the older names) is long, and its summary table is misleading. It lists `JobService.InsertJob` and `Query`, and the old `jobservice.insert`, `jobservice.query` and `jobservice.getqueryresults`, under `ADMIN_WRITE`, which means Admin Activity. Each method's own entry says Data Access. In our logs every job entry, old and new format, was in Data Access. `InsertJob` showed up in Admin Activity only for the `tableCreation` and `tableChange` events of jobs that created or changed a table. Open the playground at https://app.querylab.io?playground=true&utm_source=docs&utm_medium=concepts to run it. It is the real app on public BigQuery datasets and needs no sign-in. ## Sources Based on the BigQuery documentation by Google, CC BY 4.0, rewritten with examples checked against BigQuery: - [Introduction to audit logs in BigQuery (Last updated 2026-10-05)](https://cloud.google.com/bigquery/docs/introduction-audit-workloads) - [BigQuery audit logs overview (Last updated 2026-10-05)](https://cloud.google.com/bigquery/docs/reference/auditlogs) - [Audit logs migration guide (Last updated 2026-10-05)](https://cloud.google.com/bigquery/docs/reference/auditlogs/migration) - [Cloud Audit Logs overview (Last updated 2026-09-30)](https://cloud.google.com/logging/docs/audit) - [Route logs to BigQuery (Last updated 2026-09-30)](https://cloud.google.com/logging/docs/export/bigquery) - [Cloud Logging pricing](https://cloud.google.com/stackdriver/pricing)