Google Sheets
catalog.spreadsheet.tabOne Google Sheets catalog is one Drive folder. Every spreadsheet directly inside the folder becomes a schema named after the file, and every tab becomes a table named after the tab:
sales_sheets the catalog: a Drive folder
└── Q1 2026 a schema: the spreadsheet's file name
├── Orders a table: a tab
└── Pipeline another tab of the same fileSubfolders are not followed. If your spreadsheets live in several folders, create one catalog per folder.
What you need
| Field | Notes |
|---|---|
| Drive folder | The folder's link (https://drive.google.com/drive/folders/…) or its id |
| Credentials JSON | A Google service-account key, pasted once |
Only the service account's email is kept in the clear, so you can see which identity a catalog runs as; the private key goes to the secrets manager.
A spreadsheet link (…/spreadsheets/d/…) is refused — the catalog is the
folder, not the file. The folder must be shared with the service account's
address: Viewer to query, Editor to export into a tab.
Google side, step by step
These steps are done once, by whoever owns the folder.
-
Enable the Drive and Sheets APIs in a Google Cloud project. Any project works, including one you already use for BigQuery: Drive API (opens in a new tab), Sheets API (opens in a new tab).
-
Create a service account. In IAM & Admin → Service accounts, create one; the name is cosmetic. It needs no project-level role — it reaches your data through the Drive share in step 4, not through IAM.
-
Create a JSON key. Open the service account → Keys → Add key → Create new key → JSON. The file downloads once: that is the credentials JSON you paste into Thunderduck.
-
Share the folder in Google Drive with the service account's address, which looks like
[email protected](theclient_emailin the JSON). Give it Viewer to read, Editor to also export into tabs. Uncheck Notify people — a service account has no inbox.- In a Shared Drive, add the address as a member of the Shared Drive itself (Viewer, or Contributor to write), or allow sharing with non-members and share the folder.
- In a Google Workspace organization, an admin policy may block sharing with an address outside the domain. That one account has to be allowed.
-
Create the catalog. Name it (lowercase letters, digits and underscores), paste the folder link and the JSON. Collection starts immediately, so a folder that was not shared with the service account comes back marked offline with the reason, instead of failing at your first query.
There is no OAuth screen and no per-user consent: the catalog runs as the service account, so everyone in the company who can read the catalog reads exactly the files shared with that address.
Looking after the key
The JSON can read — and, with Editor, write — every folder shared with the service account. Treat it like a password.
- Rotate: create a second key, update the catalog's Credentials JSON, then delete the old key in Google Cloud.
- Revoke: un-share the folder, or delete the key or the service account. Catalogs using it go offline at the next refresh.
Querying a tab
SELECT * FROM sales_sheets."Q1 2026".Orders;Quote a segment when the Drive name contains spaces or dots — "Q1 2026",
"Sales.EU". Matching is case-insensitive either way.
-- filter and aggregate
SELECT region, sum(amount) AS revenue
FROM sales_sheets."Q1 2026".Orders
WHERE order_date >= DATE '2026-01-01'
GROUP BY region
ORDER BY revenue DESC;
-- join a sheet with a database table
SELECT o.order_id, o.amount, c.email
FROM sales_sheets."Q1 2026".Orders o
JOIN crm.public.customers c ON c.id = o.customer_id;The data is live. Each query reads the tab from Google when it runs, so an edit in the spreadsheet shows up on the next query — no refresh needed. Only the names and column list are cached; that is what a refresh updates.
Column names and types come from the sheet
Row 1 holds the header, and the first data row decides each column's type. That is the same rule DuckDB's gsheets extension applies when it reads the tab, so the Tables page matches what a query returns.
| First data cell | Type |
|---|---|
| a number | DOUBLE |
TRUE / FALSE | BOOLEAN |
| anything else | VARCHAR |
- Numbers are always
DOUBLE, ids included. Cast when you need an integer:CAST(id AS BIGINT). - A tab with only a header, and no data row yet, is collected with every column
as
VARCHAR: there is no value to infer from. - The header must be row 1.
Refreshing after a change in Drive
A new spreadsheet, a renamed spreadsheet or tab, or a changed header is picked up by refreshing the catalog (the refresh button on its group in Tables, or the scheduled refresh). Until then, the old names are all that can be addressed.
Unknown Google Sheets table "…" means the name is not in the cache yet: check
the spelling, refresh, retry.
Reading is read-only
A Sheets catalog is not an attached database: there is no USE, no
CREATE TABLE, no INSERT or UPDATE, and a bare catalog.tab reference is
refused because a tab name alone does not say which spreadsheet it belongs to.
Writing goes through exports, below.
Exporting into a tab
COPY (SELECT region, sum(amount) AS revenue
FROM sales_sheets."Q1 2026".Orders
GROUP BY region ORDER BY revenue DESC)
TO 'sales_sheets/Q1 2026/Revenue by region' (FORMAT gsheet);Exactly three segments: the catalog, the spreadsheet, then the tab.
- The spreadsheet must already exist in the folder. The tab does not:
CREATE_IF_NOT_EXISTS trueis applied unless you say otherwise. - An existing tab is replaced, header included, because
OVERWRITE_SHEETdefaults to true. - This is the same
COPY (…) TO …form as a file export, with Sheets names instead of a path. A tab title cannot contain/. - Requires write access to the catalog in Thunderduck, and Editor on the folder. A catalog marked read-only refuses exports.
Writing a tab per period
The tab segment renders the usual export templates, so a scheduled job can name one tab per day or month:
COPY (SELECT * FROM sales_sheets."Q1 2026".Orders WHERE order_date = current_date - 1)
TO 'sales_sheets/Q1 2026/Daily{now,"yyyyMMdd"}' (FORMAT gsheet);Templates are always UTC. Only the tab renders: the spreadsheet segment has to
name a file that exists, so {today} there stays literal text.
Options
| Option | Default | Notes |
|---|---|---|
FORMAT | gsheet | Required; file options such as DELIMITER are refused |
HEADER | true | Write the header row (false when appending) |
OVERWRITE_SHEET | true | Clear the whole tab before writing |
OVERWRITE_RANGE | false | Clear only RANGE before writing |
RANGE | A1 range to write into, e.g. 'B2:F' | |
CREATE_IF_NOT_EXISTS | true | Create the tab when it does not exist |
Set at most one of OVERWRITE_SHEET and OVERWRITE_RANGE. With both overwrite
flags false, rows are appended to the range instead of clearing it:
COPY (SELECT * FROM sales_sheets."Q1 2026".NewOrders)
TO 'sales_sheets/Q1 2026/Orders'
(FORMAT gsheet, RANGE 'B2:F', OVERWRITE_RANGE true, HEADER false);Limits
- 500 spreadsheets per folder. Beyond that the rest are skipped and the catalog logs a warning; split large collections into several folders.
- One service account per statement. The extension resolves a single credential, so a query that touches two Sheets catalogs with different service accounts is refused. Two folders shared with the same account are fine.
- Tab titles with
!are read by passing the title through; a title that contains both!and a quote cannot be addressed by the extension and is refused by name. Rename that tab. - No new spreadsheets. An export writes tabs inside a spreadsheet that already exists; creating the file is a Drive action this catalog does not do.
Troubleshooting
| Message | Cause and fix |
|---|---|
Unknown Google Sheets table "…" | The spreadsheet or tab is not in the cache: fix the name, or refresh |
… is a Google Sheets catalog; address a tab as … | You wrote catalog.tab; add the spreadsheet segment |
Unknown Google Sheets spreadsheet "…" on export | No such file in the folder, or the name is misspelled. Refresh, or use the spreadsheet id as the middle segment |
Exporting into catalog … needs write access | Grant write on the catalog, and Editor on the folder |
| Catalog badge says offline with a credentials reason | The key was deleted or rotated, or the folder is no longer shared with the service account |
Table with name … does not exist | Wrong spreadsheet or tab, or a name that needs quoting |
See Exporting files for templates, scheduling and the other destinations, and Catalogs for how addressing differs per type.