kit
BigQuery for Spring Boot

Read BigQuery in Avro or Arrow,
test it on an emulator.

Spring Boot integration for Google BigQuery, built around the Storage Read API, with an emulator you can test against.

Java 21 Spring Boot 4 Storage Read API · Avro · Arrow Testcontainers Apache-2.0
ProductRepository.java
List<GenericRecord> rows = reader.query(
        "SELECT name FROM `my-project.shop.products` WHERE price > 1.5",
        TableId.of("shop", "query_result"));
Why bqkit

Skip the REST pagination, stream the rows.

Reading a large query result through the BigQuery REST API is slow. The Storage Read API streams rows in Avro or Arrow over gRPC instead. bqkit wires that path into Spring Boot.

Avro or Arrow

Run a query into a destination table, then stream it through BigQueryReadClient: rows in Avro, or columnar batches in Arrow.

Impersonation

Application Default Credentials, with optional service account impersonation.

Emulator tests

A @ServiceConnection for the BigQuery emulator with Testcontainers: no GCP project needed.

Backs off

Every bean steps aside when your application defines its own.

The read path

One call, three steps.

One call to query(sql, destination) runs a query job, lands the result in a table, then streams that table in Avro (QueryAvroReader) or in Arrow (QueryArrowReader).

GOOGLE BIGQUERY Your application reader.query(sql, table) QueryAvroReader or QueryArrowReader Query job REST · WRITE_TRUNCATE Destination table shop.query_result Storage Read session gRPC · Avro or Arrow List<GenericRecord> or Arrow batches 1 2 3
1. Run the query · 2. Results land in the destination table · 3. Stream the table in Avro or Arrow and decode it
Modules

Take only what you need.

Use the core alone, or let the starter wire it for you.

bqkit-coreSpring-free clients: BigQueryClients, CredentialsFactory, QueryAvroReader, QueryArrowReader.
bqkit-spring-boot-starterAuto-configuration of BigQuery, BigQueryReadClient, QueryAvroReader and QueryArrowReader.
bqkit-testEmulator container and @ServiceConnection support for tests.
Usage

Add the starter, set a project, query.

Each major version of bqkit targets one Spring Boot generation.

unreleasedbqkit is not released yet: build it locally with mvn install.

pom.xml — 1. add the starter
<dependency>
    <groupId>io.github.bqkit</groupId>
    <artifactId>bqkit-spring-boot-starter</artifactId>
    <version>${bqkit.version}</version>
</dependency>
application.yml — 2. configure
bqkit:
  bigquery:
    project-id: my-project
    impersonate-service-account: reader@my-project.iam.gserviceaccount.com # optional
    location: EU # optional, location of the query jobs
java — 3. query
List<GenericRecord> rows = reader.query(
        "SELECT name FROM `my-project.shop.products` WHERE price > 1.5",
        TableId.of("shop", "query_result"));
query(sql, destination)Truncates the destination table before writing, then reads it. Concurrent calls into the same destination table race: each one truncates the table, so a call can read the rows of another, or none. Give each call its own destination table, or use query(sql), whose anonymous result table belongs to its job.
query(sql)Reads the anonymous result table that BigQuery creates for the job.
readTable(table)Reads an existing table. The read session is created in the connection project, which needs bigquery.readsessions.create and pays for the read, whatever the project of the table. The connection's service account also needs bigquery.readsessions.getData on the table.
query(configuration)Runs a QueryJobConfiguration as is, for query parameters, maximumBytesBilled or labels, then reads the table the job wrote to. It sets no write disposition, and the job runs in the location of the client. Only the result of a SELECT can be read.
query(sql, destination, rows)Same as query(sql, destination), row by row to the callback.
query(sql, rows)Same as query(sql), row by row to the callback.
query(configuration, rows)Same as query(configuration), row by row to the callback.
readTable(table, rows)Same as readTable(table), row by row to the callback.

Avro or Arrow

The starter declares both readers: inject the one that fits the use case. QueryAvroReader reads row by row: it returns the rows as a List<GenericRecord>, which holds the whole result in memory, or hands each row to a callback, for large results. QueryArrowReader hands the result to a callback, one columnar record batch at a time: to work on columns.

QueryAvroReader streams the rows with query(sql, rows), query(sql, destination, rows), query(configuration, rows) or readTable(table, rows):

java — stream Avro rows
reader.query("SELECT name FROM `my-project.shop.products`", TableId.of("shop", "query_result"),
        row -> System.out.println(row.get("name")));

The next response of the stream is only requested once every row of the current one has gone through the callback, so a slow callback slows the read down instead of filling the memory. If a row cannot be decoded or the callback throws, the read stops and the stream is cancelled: the rows before it have already been handed to the callback.

QueryAvroReader returns BigQuery values as Java types: STRING as String, TIMESTAMP as Instant, DATE as LocalDate, TIME as LocalTime, NUMERIC and BIGNUMERIC as BigDecimal, INT64 as Long, FLOAT64 as Double, BOOL as Boolean, BYTES as ByteBuffer. DATETIME, GEOGRAPHY and JSON stay String, as BigQuery sends them. STRUCT is a nested GenericRecord, ARRAY a List, and NULL is null.

QueryArrowReader takes the same arguments, and calls the callback once per record batch:

java — stream Arrow batches
reader.query("SELECT name, price FROM `my-project.shop.products`", TableId.of("shop", "query_result"), batch -> {
    VarCharVector name = (VarCharVector) batch.getVector("name");
    for (int i = 0; i < batch.getRowCount(); i++) {
        System.out.println(name.getObject(i));
    }
});

The batch is released once the callback returns: copy what you keep. Arrow needs access to java.nio on Java 21: start the application, and the tests, with --add-opens=java.base/java.nio=ALL-UNNAMED. Without it, the first read fails with Failed to initialize MemoryUtil.

Every bean backs off when the application defines its own. Defining a BigQueryConnectionDetails bean replaces the connection built from the properties.

The starter ships the configuration metadata of the bqkit.* keys: your IDE completes and documents them in application.yml.

With spring-cloud-gcp

With spring-cloud-gcp-starter-bigquery on the classpath, bqkit runs after it and reuses its BigQuery client. The Storage Read client takes the credentials of that client: the query and the read run with the spring.cloud.gcp.* project and credentials, and bqkit.bigquery.* does not apply. bqkit logs a warning at startup when bqkit.bigquery.* is set anyway. spring-cloud-gcp itself requires spring.cloud.gcp.bigquery.dataset-name: its BigQueryTemplate fails without it.

Without the starter

bqkit-core has no Spring dependency. Declare the beans yourself, with keys of your own in application.yml:

pom.xml — core only
<dependency>
    <groupId>io.github.bqkit</groupId>
    <artifactId>bqkit-core</artifactId>
    <version>${bqkit.version}</version>
</dependency>
application.yml — your own keys
bigquery:
  project-id: my-project
  impersonate-service-account: reader@my-project.iam.gserviceaccount.com # optional
  location: EU # optional, location of the query jobs
java — BigQueryConfig
@Configuration
public class BigQueryConfig {

    @Bean
    BigQueryConnection bigQueryConnection(
            @Value("${bigquery.project-id}") String projectId,
            @Value("${bigquery.impersonate-service-account:}") String impersonateServiceAccount,
            @Value("${bigquery.location:}") String location) {
        return BigQueryConnection.google(projectId, CredentialsFactory.create(impersonateServiceAccount))
                .withLocation(location);
    }

    @Bean
    BigQuery bigQuery(BigQueryConnection connection) {
        return BigQueryClients.bigQuery(connection);
    }

    @Bean(destroyMethod = "close")
    BigQueryReadClient bigQueryReadClient(BigQueryConnection connection) {
        return BigQueryClients.readClient(connection);
    }

    @Bean
    QueryAvroReader queryAvroReader(BigQuery bigQuery, BigQueryReadClient readClient) {
        return new QueryAvroReader(bigQuery, readClient);
    }

    @Bean
    QueryArrowReader queryArrowReader(BigQuery bigQuery, BigQueryReadClient readClient) {
        return new QueryArrowReader(bigQuery, readClient);
    }
}

BigQueryConnection.google(...) targets the Google endpoints; BigQueryConnection.emulator(...) targets an emulator. The @ServiceConnection of bqkit-test relies on the starter.

Credentials

Read with a service account's permissions.

Set impersonate-service-account to read with a service account's permissions instead of your own.

Application Default Credentials Scoped cloud-platform Impersonated SA optional · 1 h tokens BigQuery REST + gRPC no service account set
Testing

Declare the emulator, Spring Boot does the rest.

Declare the emulator container: Spring Boot connects every bqkit bean to it.

pom.xml — test dependency
<dependency>
    <groupId>io.github.bqkit</groupId>
    <artifactId>bqkit-test</artifactId>
    <version>${bqkit.version}</version>
    <scope>test</scope>
</dependency>
java — emulator container
@SpringBootTest
@Testcontainers
class ProductRepositoryTest {

    @Container
    @ServiceConnection
    static final BqkitBigQueryEmulatorContainer EMULATOR =
            new BqkitBigQueryEmulatorContainer();
}
DOCKER · TESTCONTAINERS @ServiceConnection bqkit-test ConnectionDetails testproject · plaintext bqkit beans BigQuery · ReadClient goccy/bigquery-emulator REST :9050 gRPC :9060 · Avro

BqkitBigQueryEmulatorContainer runs DEFAULT_IMAGE, the goccy/bigquery-emulator image bqkit is tested against, on project testproject. @ServiceConnection only accepts this container: the Testcontainers BigQueryEmulatorContainer starts the emulator on test-project, and the emulator's Avro codec rejects the hyphen of that id. To pull the image from a mirror, pass a DockerImageName declared with asCompatibleSubstituteFor("ghcr.io/goccy/bigquery-emulator"). With spring-cloud-gcp on the classpath, exclude GcpBigQueryAutoConfiguration in tests: its client would keep querying Google while bqkit reads from the emulator.

A complete test

The application code queries through QueryAvroReader:

java — ProductRepository
@Repository
public class ProductRepository {

    private final QueryAvroReader reader;

    public ProductRepository(QueryAvroReader reader) {
        this.reader = reader;
    }

    public List<String> findNamesInStock() {
        TableId result = TableId.of("shop", "products_in_stock_" + UUID.randomUUID().toString().replace('-', '_'));
        return reader.query("SELECT name FROM shop.products WHERE stock > 0", result).stream()
                .map(row -> (String) row.get("name"))
                .toList();
    }
}

Each call writes to its own destination table, so concurrent calls do not race. Set a default table expiration on the dataset so that these tables do not pile up.

The test seeds the emulator with the BigQuery bean, calls the repository, then checks the result:

java — ProductRepositoryTest
@SpringBootTest
@Testcontainers
class ProductRepositoryTest {

    @Container
    @ServiceConnection
    static final BqkitBigQueryEmulatorContainer EMULATOR =
            new BqkitBigQueryEmulatorContainer();

    private static final TableId PRODUCTS = TableId.of("shop", "products");

    @Autowired
    BigQuery bigQuery;

    @Autowired
    ProductRepository repository;

    @Test
    void finds_the_names_of_the_products_in_stock() {
        // Given
        bigQuery.create(DatasetInfo.of(PRODUCTS.getDataset()));
        bigQuery.create(TableInfo.of(PRODUCTS, StandardTableDefinition.of(Schema.of(
                Field.of("name", StandardSQLTypeName.STRING),
                Field.of("stock", StandardSQLTypeName.INT64)))));
        bigQuery.insertAll(InsertAllRequest.newBuilder(PRODUCTS)
                .addRow(Map.of("name", "banana", "stock", 3))
                .addRow(Map.of("name", "apple", "stock", 0))
                .addRow(Map.of("name", "cherry", "stock", 12))
                .build());

        // When
        List<String> names = repository.findNamesInStock();

        // Then
        assertThat(names).containsExactlyInAnyOrder("banana", "cherry");
    }
}

The emulator keeps its data for the lifetime of the container: with several tests in a class, give each its own dataset or destination table (see Emulator limits).

Emulator limits

The emulator is not BigQuery.

Keep these in mind when a test behaves differently from production.

avro

Hyphenated project ids break Avro reads. The emulator builds the Avro namespace from the project id, so test-project (the Testcontainers default) fails. testcontainers-java#12123 proposes withProjectId.

bqkit uses testproject.

truncate

WRITE_TRUNCATE is ignored. query(sql, destination) appends to the destination table instead of replacing it, so a second query into the same table returns the rows of both.

Use a distinct destination table per test, or drop it before each test.

time

TIME fails in Avro. The emulator cannot send a TIME column in Avro (cannot transform to binary time-micros).

BigQuery Emulator after 1st apple pear after 2nd apple pear 2 rows: table replaced after 1st apple pear after 2nd apple pear apple (appended) pear (appended) 4 rows: duplicates
Same query, same destination table, run twice

prodNone of these limits affect BigQuery itself.

Build

Build it with Maven.

The tests need Docker.

shell
mvn verify