How to Connect to BigQuery from Rust

6 min readBigQuery

BigQuery has no host, no port, and no wire protocol to speak -- it's a REST API, so "connecting" from Rust means picking an HTTP client library and an auth mechanism. Unlike Go or Python, Google does not ship an official BigQuery client for Rust (as of July 2026), so the ecosystem is community crates. Two are worth considering, and one naming migration causes most of the confusion.

The options

LibraryOriginNotesVersion (as of July 2026)
gcp-bigquery-clientCommunity (lquerel)Most-used, covers all REST endpoints, partial Storage Write API0.28.0
gcloud-bigqueryCommunity (yoshidan)Query + Storage Read API, part of the gcloud-* family1.7.0
google-cloud-bigqueryFormerly yoshidan'sFrozen at 0.15.0 (Feb 2025) -- namespace donated to Googledon't use

The renaming trap

The crate literally named google-cloud-bigquery is the one you should NOT install. In February 2025 the yoshidan project donated the google-cloud-* names on crates.io to Google (whose own google-cloud-rust effort covers many GCP services but has not shipped a BigQuery data-plane client). The maintained continuation of that codebase publishes as gcloud-bigquery. If a tutorial tells you google-cloud-bigquery = "0.15", you're reading pre-2025 material -- same code, now updated under the new name:

# maintained fork of the same library:
google-cloud-bigquery = { package = "gcloud-bigquery", version = "1" }

Authentication: the ADC ladder

Everything here mirrors the other BigQuery guides (Python, Node, Go): credentials come from Application Default Credentials or an explicit service account key.

  1. GOOGLE_APPLICATION_CREDENTIALS env var pointing at a service account JSON key
  2. gcloud auth application-default login (local development)
  3. The attached service account (GCE, Cloud Run, GKE)

You need roles/bigquery.jobUser on the project you bill queries to and roles/bigquery.dataViewer on the datasets you read. Those are separate things -- more below.

gcp-bigquery-client

[dependencies]
gcp-bigquery-client = "0.28"
tokio = { version = "1", features = ["full"] }

Client init and a query:

use gcp_bigquery_client::model::query_request::QueryRequest;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // From an explicit key file...
    let client = gcp_bigquery_client::Client::from_service_account_key_file(
        "/path/to/key.json",
    ).await?;
    // ...or from the ADC ladder:
    // let client = gcp_bigquery_client::Client::from_application_default_credentials().await?;
 
    let project_id = "my-billing-project";
    let response = client
        .job()
        .query(
            project_id,
            QueryRequest::new(
                "SELECT region, COUNT(*) AS cnt \
                 FROM `my-data-project.analytics.events` \
                 WHERE event_date >= '2026-01-01' \
                 GROUP BY region ORDER BY cnt DESC",
            ),
        )
        .await?;
 
    let mut rs = gcp_bigquery_client::model::query_response::ResultSet::new_from_query_response(response);
    while rs.next_row() {
        println!(
            "{}: {}",
            rs.get_string_by_name("region")?.unwrap_or_default(),
            rs.get_i64_by_name("cnt")?.unwrap_or(0)
        );
    }
    Ok(())
}

Notes:

  • The project_id you pass to .query() is the billing project (where the job runs). The project in the table reference is where the data lives. They can differ, and the IAM roles attach to each separately.
  • Results come back as JSON over REST; getters are typed (get_i64_by_name, get_string_by_name, ...) and return Option because every BigQuery column is potentially NULL.
  • The crate covers datasets, tables, jobs, models and routines, plus partial support for the Storage Write API for high-throughput streaming inserts. Auth flows go through yup-oauth2 (service account keys, workload identity, installed flow).

Parameterized queries

Don't format user input into SQL strings. QueryRequest carries named parameters:

use gcp_bigquery_client::model::{
    query_request::QueryRequest,
    query_parameter::QueryParameter,
    query_parameter_type::QueryParameterType,
    query_parameter_value::QueryParameterValue,
};
 
let mut req = QueryRequest::new(
    "SELECT COUNT(*) AS cnt FROM `my-data-project.analytics.events` WHERE region = @region",
);
req.query_parameters = Some(vec![QueryParameter {
    name: Some("region".to_string()),
    parameter_type: Some(QueryParameterType { r#type: "STRING".to_string(), ..Default::default() }),
    parameter_value: Some(QueryParameterValue { value: Some("emea".to_string()), ..Default::default() }),
}]);

Explicit types are required -- the REST API needs them to infer NULLs, same as every other language client.

gcloud-bigquery (yoshidan)

[dependencies]
google-cloud-bigquery = { package = "gcloud-bigquery", version = "1" }
tokio = { version = "1", features = ["full"] }
use google_cloud_bigquery::client::{Client, ClientConfig};
use google_cloud_bigquery::http::job::query::QueryRequest;
use google_cloud_bigquery::query::row::Row;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (config, project_id) = ClientConfig::new_with_auth().await?; // ADC ladder
    let client = Client::new(config).await?;
 
    let request = QueryRequest {
        query: "SELECT region, COUNT(*) FROM `analytics.events` GROUP BY region".to_string(),
        ..Default::default()
    };
    let mut iter = client.query::<Row>(&project_id.unwrap(), request).await?;
    while let Some(row) = iter.next().await? {
        let region = row.column::<Option<String>>(0)?;
        let cnt = row.column::<Option<i64>>(1)?;
        println!("{:?}: {:?}", region, cnt);
    }
    Ok(())
}

Its distinguishing feature is read_table, which pulls rows through the Storage Read API (gRPC, Arrow-encoded) instead of paging JSON through the REST API -- significantly faster for bulk extraction of large tables. Decoded types map to String, i64, f64, bigdecimal::BigDecimal, time::OffsetDateTime, and Option<T> for nullable columns.

Cost controls

BigQuery bills by bytes scanned, and a Rust service in a retry loop can burn a budget fast. Two REST-level controls both crates expose through QueryRequest:

  • dry_run: Some(true) -- the job returns total_bytes_processed without executing. Log it in development.
  • maximum_bytes_billed -- the job fails upfront if it would scan more than the cap, instead of quietly costing money.

SELECT * on a partitioned table without a partition filter is the classic accident; scanning is priced per column read, so select only what you need.

The dataset-location trap

If your dataset lives in EU (or any regional location) and the query job runs in the default US, the API returns "Not found: Dataset" -- an error that looks exactly like a permissions or typo problem. Set the job location to match the dataset (QueryRequest.location / job config) when you see a 404 for a dataset you can plainly see in the console. Same trap, same fix, in every language.

Common errors

ErrorCauseFix
Not found: Dataset (but it exists)Job location ≠ dataset locationSet location on the query/job to match the dataset
403 accessDenied on queryMissing bigquery.jobUser on billing projectGrant jobUser where the job runs, dataViewer where data lives
Could not load the default credentialsNo ADC foundSet GOOGLE_APPLICATION_CREDENTIALS or run gcloud auth application-default login
Query exceeds maximum_bytes_billedCost cap doing its jobNarrow columns / add partition filter, or raise the cap deliberately
Compile error: types moved/renamed after upgradeOld google-cloud-bigquery 0.15 code vs gcloud-bigquery 1.xDepend on package = "gcloud-bigquery" and follow its changelog

Verifying your data

Rust is usually the write path here -- ingestion services, ETL, Storage Write API streaming. When you need to check what actually landed without spinning up a REPL, Mako connects to BigQuery (and 8 other databases) with AI-powered autocomplete. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.