Skip to main content

Working with databases

Each project can hold Postgres databases, and the API reaches them at three levels: the database, its tables and columns, and the rows inside them. Start with List databases — every other endpoint takes a database id.

Database permissions are unusual in one way worth understanding before you grant them. Most APIs split access into read and write. These split it by what an action can destroy, because a database has nothing to recover from — there is no branch, no checkout, and a dropped column is gone:

PermissionNameAllows
workflows.runRunStart the workflow, synchronously or through the queue, and expose it over MCP.
workflows.readReadList the workflow and poll the status of its runs.
library.readBrowseList directories and files, and download file contents.
library.writeUploadCreate files and complete uploads.
library.deleteDeletePermanently remove files from the library.
databases.readReadList databases and tables, and read a table's schema and rows.
databases.writeWriteInsert rows, update cells, and start CSV imports.
databases.deleteDelete rowsPermanently delete rows. Does not allow dropping tables or columns.
databases.schemaChange schemaCreate and delete tables and columns. Deleting either destroys the data it held, so grant this only where structure genuinely changes.
databases.queryRun SQLRun arbitrary SQL. A SELECT and a DROP cannot be told apart without parsing the statement, so this grant is as powerful as the worst query it could carry — treat it as full control of the database.
databases.adminProvisionCreate and destroy whole databases. A new database is real, billable infrastructure from the moment it exists.
Run SQL is not a read permission

It is tempting to grant Run SQL to something that only needs to read. Don't. A SELECT and a DROP TABLE arrive as the same kind of request, and nothing can tell them apart without parsing the statement — so the permission is as powerful as the worst query it could carry. If the caller only needs to read rows, give it read and point it at Read rows.

Identifiers. Table and column names are written straight into SQL, so they must be lower snake case — lowercase letters and digits, single underscores between them. Anything else is rejected with a 400 rather than quoted or escaped.

Inserting. Rows are objects keyed by column name, and any column that does not exist yet is created from the value you send:

Insert two rows

curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: proj.KEY_ID.SECRET" \
-d '{"rows":[{"customer":"ada","total":42.5},{"customer":"grace","total":18}]}' \
{{BASE_URL}}/projects/{{PROJECT_ID}}/databases/DATABASE_ID/tables/orders/rows
A typo adds a column

Because missing columns are created on insert, sending totl instead of total quietly adds a totl column rather than failing. Create your columns up front if you would rather that be an error.

Reading. Read rows covers projection, ordering and paging without any SQL, and needs only the read permission. It returns at most 1000 rows per request, 100 by default — page with limit and offset.

The ten most recent orders

curl -H "Authorization: proj.KEY_ID.SECRET" \
'{{BASE_URL}}/projects/{{PROJECT_ID}}/databases/DATABASE_ID/tables/orders/rows?limit=10&orderBy=created_at&orderDirection=DESC'

Bulk loading. Uploading rows a request at a time is the wrong tool past a few thousand. Put the CSV in the library, then start an import and poll it:

  1. Upload the CSV with the library endpoints, or use a file already there.
  2. Call Read a CSV header to see its columns, if you need to build a mapping.
  3. Call Import a CSV to start the load — it returns an import id.
  4. Poll Get CSV import status until it reports finished.
Importing needs two permissions

An import reads a library file and writes database rows, so the key needs database write and library read. Without both it is refused with a 403.

Import a CSVPOST /projects/:projectid/databases/:dbid/tables/:tblid/import-csv