BigQuery
Lightweight, dependency-free fake of Google Cloud BigQuery that speaks the real BigQuery v2 REST API (https://bigquery.googleapis.com/bigquery/v2), so application code using @google-cloud/bigquery can run against it with zero cost and zero side effects.
| Key | Value |
|---|---|
| Port | 4583 |
| Protocol | BigQuery v2 REST API (HTTP/1.1 + JSON) |
| Compatible client | @google-cloud/bigquery (v8) |
| Size | ~80 KB |
| Startup | < 100ms |
| State | In-memory, ephemeral, resettable |
Quick Start
Start the server:
import { BigqueryServer } from "./services/bigquery/src/server.js";
const server = new BigqueryServer(4583);
await server.start();
// ... use it ...
await server.stop();
Connect with the real BigQuery client. Two things route it to the parlel fake:
- Set
BIGQUERY_EMULATOR_HOSTbefore constructing the client — its value becomes the client'sbaseUrl. - Inject an offline auth client so the common layer does not attempt a real OAuth token exchange against Google. The fake never validates tokens.
process.env.BIGQUERY_EMULATOR_HOST = "http://127.0.0.1:4583";
import { BigQuery } from "@google-cloud/bigquery";
import { GoogleAuth } from "google-auth-library";
// A no-network auth client: authorizeRequest is a no-op that injects a fake
// bearer token. The fake never checks it.
const auth = new GoogleAuth({ projectId: "parlel" });
auth.authorizeRequest = async (opts = {}) => {
opts.headers = opts.headers || {};
opts.headers.Authorization = "Bearer parlel-fake-token";
return opts;
};
auth.getProjectId = async () => "parlel";
const bq = new BigQuery({ projectId: "parlel", authClient: auth });
// Create a dataset + table
const [dataset] = await bq.createDataset("analytics");
const [table] = await dataset.createTable("events", {
schema: [
{ name: "id", type: "INTEGER" },
{ name: "name", type: "STRING" },
{ name: "ts", type: "TIMESTAMP" },
],
});
// Stream rows in
await table.insert([
{ id: 1, name: "signup", ts: "2024-01-01T00:00:00Z" },
{ id: 2, name: "purchase", ts: "2024-01-02T00:00:00Z" },
]);
// Query
const [rows] = await bq.query("SELECT name FROM analytics.events WHERE id = @id", {
params: { id: 1 },
});
console.log(rows); // [{ name: "signup" }]
// Parameterised / job-based query
const [job] = await bq.createQueryJob({ query: "SELECT COUNT(*) AS c FROM analytics.events" });
const [results] = await job.getQueryResults();
console.log(results); // [{ c: 2 }]
The endpoint URL the client composes is
{BIGQUERY_EMULATOR_HOST}/projects/{projectId}/{uri}— this server implements the full BigQuery v2 resource tree under/projects/{projectId}/….
Implemented operations
Datasets
| Operation | Method + path |
|---|---|
datasets.insert (createDataset) | POST /projects/{p}/datasets |
datasets.list (getDatasets) | GET /projects/{p}/datasets |
datasets.get (getMetadata / exists) | GET /projects/{p}/datasets/{d} |
datasets.patch (setMetadata) | PATCH /projects/{p}/datasets/{d} |
datasets.delete (delete, deleteContents) | DELETE /projects/{p}/datasets/{d} |
Tables
| Operation | Method + path |
|---|---|
tables.insert (createTable) | POST /projects/{p}/datasets/{d}/tables |
tables.list (getTables) | GET /projects/{p}/datasets/{d}/tables |
tables.get (getMetadata / exists) | GET /projects/{p}/datasets/{d}/tables/{t} |
tables.patch (setMetadata) | PATCH /projects/{p}/datasets/{d}/tables/{t} |
tables.delete (delete) | DELETE /projects/{p}/datasets/{d}/tables/{t} |
tabledata.list (getRows) | GET /projects/{p}/datasets/{d}/tables/{t}/data |
tabledata.insertAll (insert) | POST /projects/{p}/datasets/{d}/tables/{t}/insertAll |
tables.getIamPolicy | POST …/tables/{t}:getIamPolicy |
tables.setIamPolicy | POST …/tables/{t}:setIamPolicy |
tables.testIamPermissions | POST …/tables/{t}:testIamPermissions |
Jobs & Queries
| Operation | Method + path |
|---|---|
jobs.insert (createJob / createQueryJob / createLoadJob / createCopyJob / extract) | POST /projects/{p}/jobs |
jobs.list (getJobs) | GET /projects/{p}/jobs |
jobs.get (getMetadata) | GET /projects/{p}/jobs/{j} |
jobs.cancel (cancel) | POST /projects/{p}/jobs/{j}/cancel |
jobs.delete (delete) | DELETE /projects/{p}/jobs/{j}/delete |
jobs.query (query) | POST /projects/{p}/queries |
jobs.getQueryResults (getQueryResults) | GET /projects/{p}/queries/{j} |
Routines
| Operation | Method + path |
|---|---|
routines.insert (createRoutine) | POST /projects/{p}/datasets/{d}/routines |
routines.list (getRoutines) | GET /projects/{p}/datasets/{d}/routines |
routines.get (getMetadata / exists) | GET /projects/{p}/datasets/{d}/routines/{r} |
routines.update (setMetadata) | PUT /projects/{p}/datasets/{d}/routines/{r} |
routines.delete (delete) | DELETE /projects/{p}/datasets/{d}/routines/{r} |
Models
| Operation | Method + path |
|---|---|
models.list (getModels) | GET /projects/{p}/datasets/{d}/models |
models.get (getMetadata / exists) | GET /projects/{p}/datasets/{d}/models/{m} |
models.patch (setMetadata) | PATCH /projects/{p}/datasets/{d}/models/{m} |
models.delete (delete) | DELETE /projects/{p}/datasets/{d}/models/{m} |
Internal parlel endpoints (not part of BigQuery)
| Endpoint | Purpose |
|---|---|
GET /_parlel/health | Liveness + counts (datasets, jobs) |
POST /_parlel/reset | Drop all in-memory state |
GET /_parlel/dump | List dataset + job ids |
SQL engine
Queries (jobs.query and query jobs) run through a small, pragmatic SQL subset
sufficient for testing application logic:
SELECT <literals>with noFROM(e.g.SELECT 1 AS one, 'hi' AS greeting)SELECT * | col, col AS alias FROM [project.]dataset.tableWHEREwith=, !=, <>, >, <, >=, <=,IS [NOT] NULL, andAND-joined predicatesORDER BY col [ASC|DESC](multiple keys)LIMIT nSELECT COUNT(*) [AS alias] FROM …- Named (
@name) and positional (?) query parameters - DDL/DML statements (
CREATE/INSERT/UPDATE/DELETE/MERGE/DROP) are accepted and reported with the correctstatementTypebut produce no rows.
Rows are returned in BigQuery's wire shape — { f: [{ v }, …] } paired with a
schema.fields array — and the client merges them back into plain objects.
Surface coverage
This emulator faithfully replicates the API surface most application code and agents exercise. Anything below the supported lines is either an intentional design choice for a fast, zero-cost local emulator (✓ By design) or a candidate for a future release (⟳ Roadmap) — never a silent inaccuracy.
Legend: ✅ fully supported · ◐ accepted (stored, not strictly enforced) · ✓ by design · ⟳ on the roadmap.
| Feature | Status |
|---|---|
| Dataset CRUD | ✅ Supported |
| Table CRUD (incl. views) | ✅ Supported |
Streaming inserts (insertAll) | ✅ Supported |
Table data listing + pagination (maxResults, startIndex, pageToken) | ✅ Supported |
Query (jobs.query fast path + query jobs) | ✅ Supported (SQL subset) |
| Query parameters (named + positional) | ✅ Supported |
getQueryResults (with pagination) | ✅ Supported |
Job lifecycle (insert/get/list/cancel/delete) — completes instantly as DONE | ✅ Supported |
Copy jobs (incl. WRITE_TRUNCATE/WRITE_EMPTY) | ✅ Supported |
| Load jobs (metadata + destination-table creation) | ✅ Accepted; source bytes via resumable upload are not hosted |
| Extract jobs | ✅ Accepted as no-op (no object storage) |
| Routines CRUD | ✅ Supported |
| Models (list/get/patch/delete) | ✅ Supported (no models.insert — created via CREATE MODEL, which is a no-op) |
| Table IAM policy (get/set/test) | ✅ Supported (test grants all) |
SELECT with WHERE (typed comparisons), ORDER BY, LIMIT, COUNT(*), projection, parameters | ✅ Supported |
JOINs / multi-column GROUP BY / window functions / UDFs | ⟳ Roadmap — rejected with a 400 invalidQuery error, never silently wrong rows |
Resumable/multipart upload endpoint for load from local files | ⟳ Roadmap — Not hosted |
| Real query cost / bytes-billed accounting | ⟳ Roadmap — Always reports 0 |
| Authentication / token validation | ✓ By design — Any non-empty credential is accepted — no real secrets needed |
| Asynchronous long-running jobs | ⟳ Roadmap — All jobs complete synchronously (DONE) |
Error codes & shapes
Errors follow the Google-API JSON envelope the client expects:
{
"error": {
"code": 404,
"message": "Not found: Dataset parlel:missing",
"status": "NOT_FOUND",
"errors": [
{ "message": "Not found: Dataset parlel:missing", "domain": "global", "reason": "notFound" }
]
}
}
The HTTP status equals error.code. Common codes:
| HTTP | status | reason | When |
|---|---|---|---|
| 400 | INVALID_ARGUMENT | required / invalid / parseError / resourceInUse | Missing id, bad SQL, invalid JSON, deleting a non-empty dataset without deleteContents |
| 404 | NOT_FOUND | notFound | Missing dataset / table / job / routine / model, or query referencing a missing table |
| 409 | ALREADY_EXISTS | duplicate | Creating a dataset/table/routine that already exists |
| 405 | UNIMPLEMENTED | notImplemented | Unsupported method on a known resource |
Streaming insert validation errors are returned inside a 200 response as
insertErrors: [{ index, errors: [{ reason, message }] }], matching
tabledata.insertAll; the client raises a PartialFailureError from them.
Configuration — test.env
Copy these into your test.env (used by the bridge sidecar flow). Tokens are Parlel's seeded test credentials — any non-empty value is accepted by the emulator, so you rarely need to change them. Swap in real credentials only when pointing at the live service in prod.env.
BIGQUERY_EMULATOR_HOST=http://parlel-bridge:4583
GOOGLE_CLOUD_PROJECT=parlel
GCLOUD_PROJECT=parlel
<!-- parlel:testenv:end -->