# pyquickbase > An unofficial, typed Python SDK for the Quickbase API, with sync and async clients. pyquickbase is unofficial. It isn't affiliated with or endorsed by Quickbase, Inc. These are all the docs for version 1.0.0rc13 (`pyquickbase.__version__`), as one file. The [playground](https://matthew-williams-solutions.github.io/pyquickbase/playground/) is left out because it's interactive. A list of the pages is in [llms.txt](https://matthew-williams-solutions.github.io/pyquickbase/llms.txt). ## Home Source:

A typed Python SDK for the Quickbase API, with matching sync and async clients, and policies that limit what a client, or an AI agent, can do.

[Get started](https://matthew-williams-solutions.github.io/pyquickbase/getting-started/){ .md-button .md-button--primary } [Try it in your browser](https://matthew-williams-solutions.github.io/pyquickbase/playground/){ .md-button } ```python from datetime import date from pyquickbase import F, Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") overdue = (F("Status") != "Done") & (F("Due Date") < date.today()) for task in tasks.query(where=overdue, select=["Title", "Due Date"], order_by="-Due Date"): print(task.id, task["Title"], task["Due Date"]) tasks.update([{"Record ID#": 7, "Status": "Done"}]) ``` ### Why use it
- **Names, not IDs** Field labels and table names everywhere, with real `date` and `datetime` values. - **Queries that can't silently fail** `F("Status") == "Done"` instead of `{7.EX.'Done'}`, checked before it's sent. - **Errors that say what happened** A failed row raises `WriteError`, listing what was written and what wasn't. - **[Policies](https://matthew-williams-solutions.github.io/pyquickbase/policies/)** Limit a client to the tables, fields, and records its job needs. - **[Built for AI agents](https://matthew-williams-solutions.github.io/pyquickbase/agents/)** Typed operations for tool calls, results as plain data, and these docs as [llms.txt](https://matthew-williams-solutions.github.io/pyquickbase/llms.txt).
The Quickbase REST API accepts many mistakes without complaint. A lowercase operator acts like "contains", an unknown field matches nothing, a failed row sits inside a successful response, and writing `null` to a field does nothing. This SDK turns those into errors or correct behavior. | With the REST API | With pyquickbase | |---|---| | Numeric field IDs everywhere | Field labels and table names, or IDs if you prefer | | `{7.EX.'Done'}AND{9.BF.'today'}` | `(F("Status") == "Done") & (F("Due Date") < date.today())` | | Broken queries return plausible results | Broken queries raise `QueryError` before sending | | Manual `skip` and `top` paging | `for record in table.query(...)` | | Rows like `{"7": {"value": "Done"}}` | `record["Status"]`, with real `date` and `datetime` values | | Failed rows inside a successful response | `WriteError`, listing what failed and what was written | ### See the difference You import three rows, and one has an invalid status: ```python rows = [ {"Title": "Order chairs", "Status": "Open"}, {"Title": "Book venue", "Status": "Finished"}, # Not a valid status {"Title": "Send invites", "Status": "Open"}, ] ``` The REST API saves the other two and returns HTTP 207. That's a success status, so a normal check passes, and the failed row is only listed inside the response body: ```python response = httpx.post(url, headers=headers, json=body) response.raise_for_status() # Passes, because 207 is a success status print("Import finished") # But one row wasn't saved ``` pyquickbase checks the response for you and raises an error that says what happened: ```python try: tasks.insert(rows) except WriteError as error: print("Saved:", error.result.created_ids) for failed in error.result.errors: print("Failed:", rows[failed.index]["Title"], "-", *failed.messages) ``` ```text Saved: (25, 26) Failed: Book venue - The field with ID '7' has an invalid choice. ``` [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#an-import-that-loses-a-row), which uses simulated data based on [recorded API behavior](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#writes). ### Install ```bash pip install pyquickbase==1.0.0rc13 ``` It needs Python 3.11 or newer. Next, see [Getting started](https://matthew-williams-solutions.github.io/pyquickbase/getting-started/). ## Getting started Source: ### Before you start You need three things from Quickbase: - **Your realm**, the `acme` in `acme.quickbase.com`. - **A user token** with access to your app. Quickbase explains how to create one in its [authentication docs](https://developer.quickbase.com/auth). Treat it like a password, and give it access to as few apps as possible. - **Your app's ID**, which appears in the app's URL and usually starts with `b`. ### Install ```bash pip install pyquickbase==1.0.0rc13 ``` ### Read some records Both clients have the same API. Use the sync client for scripts and notebooks, and the async client in applications that already use asyncio or Trio. **Sync:** ```python from pyquickbase import F, Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") for task in tasks.query(where=F("Status") == "Done", select=["Title"]): print(task.id, task["Title"]) ``` **Async:** ```python from pyquickbase import AsyncQuickbase, F async with AsyncQuickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") async for task in tasks.query(where=F("Status") == "Done", select=["Title"]): print(task.id, task["Title"]) ``` To keep the token out of your code, set `QB_REALM` and `QB_USER_TOKEN` in the environment and use `Quickbase.from_env()`. ```python from pyquickbase import Quickbase with Quickbase.from_env() as qb: print(qb.app("bq1234567").info().name) ``` ### Write a record ```python tasks.insert([{"Title": "Pour slab", "Status": "Not Started"}]) ``` ### How the objects fit together A client gives you apps, an app gives you tables, and a table gives you its records, fields, reports, and operations. Getting an app or a table sends nothing. They're checked when first used. ```mermaid flowchart TD QB["Quickbase
the client"] -- "qb.app(id)" --> App[App] App -- "app.table(name)" --> T[Table] T --> TO["records, fields,
reports, operations"] ``` ### Next steps - [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/) covers queries, paging, and record values. - [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/) covers inserts, updates, upserts, and deletes. - [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/) lists what can go wrong. ## Reading records Source: This page covers finding tables and fields, querying records, reading their values, and running reports. To write records, see [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/). ```python from pyquickbase import Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") print(tasks.count()) ``` The other examples on this page use `qb` and `tasks`, made in the same way, with the sync client. With the async client, use `await` on each call and `async for` on `query()`. > **Tip: Try it without installing anything** > > The [playground](https://matthew-williams-solutions.github.io/pyquickbase/playground/) runs these in your browser, against simulated sample data. ### Tables and fields `qb.app()` and `app.table()` don't contact Quickbase. A table is found by name or ID when you first use it, and only within that app, so a table in another app raises `UnknownTableError`. ```python app = qb.app("bq1234567") tasks = app.table("Tasks") print(app.info().name) print([table.name for table in app.tables()]) print([(field.id, field.label, field.type) for field in tasks.fields()]) print(tasks.relationships()) # Only those where this table is the child ``` Metadata is cached for five minutes. Set `schema_ttl` on the client to change that, or call `qb.clear_schema_cache()` after changing a table outside this client. A name or label that isn't in the cache triggers one refresh before it counts as unknown. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#explore-the-app), with simulated data. ### Getting one record `first()` returns the first match, and `get()` finds a record by its Record ID#. Each returns None if there's no such record, rather than raising an error. ```python task = tasks.first(where=F("Status") == "Open", order_by="Due Date", select=["Title"]) same_task = tasks.get(7, select=["Title"]) ``` ### Writing queries Build conditions with `F`, naming a field by label or ID. Combine them with `&` and `|`, and wrap each one in parentheses, because Python gives those operators high precedence. ```python from datetime import date from pyquickbase import F where = ((F("Status") == "Open") | (F("Status") == "Blocked")) & (F("Due Date") < date.today()) print(tasks.count(where)) ``` | Python | Matches | |---|---| | `F("Status") == "Done"`, `!=` | Equal or not equal, ignoring case | | `F("Hours") > 10`, `>=`, `<`, `<=` | Numbers and dates | | `F("Notes") == None` | Empty fields | | `F("Active") == True` | Checkboxes | | `F("Title").contains("x")`, `.not_contains("x")` | Text anywhere in the value | | `F("Title").starts_with("x")`, `.not_starts_with("x")` | Text at the start of the value | | `F("Status").is_in(["Done", "Blocked"])` | Any of the values | | `F("Tags").has("x")`, `.not_has("x")` | One whole value of a multi-select or list-user field | | `F("Due Date").op("IR", "last 7 days")` | Any other Quickbase operator | | `F("Owner") == "a@example.com"` | A `user` or `multiuser` field, by email or user ID | Using `and`, `or`, or a chained comparison like `1 < F("x") < 5` raises `QueryError`, because Python would otherwise silently drop part of the condition. A `datetime` needs a timezone. On a date field, Quickbase reads text such as `today` or `1 day ago` as a date, and text it can't read matches nothing with no error, so prefer `date` objects. Some text would otherwise change what a query means, because Quickbase reads it as query syntax even inside quotes, so `F` handles it for you: - Values containing `{` or `}` are refused, because Quickbase can't search for them. - Values like `_FID_6` are refused, because Quickbase compares them with that field, so `F("Code") == "_FID_7"` could match every record. These can't be searched for either. - `_curuser_` is refused on a `user` or `multiuser` field, because Quickbase reads it as the token's owner, so `F("Team") == "_curuser_"` could match that person's records. To mean that on purpose, write a [query string](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/#hand-written-query-strings). - An uppercase `OR` or `AND` standing alone is sent in lowercase, because `a OR b` would otherwise match `a` and `b` rather than that text. Comparisons ignore case, so this still finds it. > **Tip: Use `F` for text you don't control** > > `F` escapes values, so user input can't change the query. A query string gets none of this > protection: there, `{6.EX.'a OR b'}` means `a` or `b`. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#combine-conditions), with simulated data. #### Hand-written query strings You can also pass a Quickbase query string, using field IDs: ```python tasks.count("({7.EX.'Done'}OR{7.EX.'Blocked'})AND{9.BF.'today'}") ``` The SDK raises `QueryError` before sending a string with a mistake that Quickbase would silently accept, such as a lowercase operator or an unknown field. It can't tell an injected condition from a real one, so don't build these strings from text you don't control. In a value, write an apostrophe as `\'`. To add checks of your own, `parse_where()` returns the conditions in a string. ```python from pyquickbase import parse_where for condition in parse_where("{7.EX.'Done'}", tasks.fields()): print(condition.field_id, condition.operator, condition.value) ``` #### Filters as plain data An AI model or a web form can't send `F`, only JSON. `where_from_dict()` turns that into the same kind of condition, and works anywhere `F` does. ```python from pyquickbase import where_from_dict where = where_from_dict( { "all": [ {"field": "Status", "op": "==", "value": "Open"}, { "any": [ {"field": "Due Date", "op": "<", "value": "2027-01-01"}, {"field": "Amount", "op": ">", "value": 100}, ] }, ] } ) print(tasks.count(where)) ``` It's stricter than `F`, because plain data can't hold a `date` object. For a model's filters, see [Filters from a model](https://matthew-williams-solutions.github.io/pyquickbase/agents/untrusted-input/#filters-from-a-model). ### Fetching records `query()` fetches further pages as you iterate, so you can stop at any point. ```python for task in tasks.query( where=F("Status") == "Open", select=["Title", "Due Date"], order_by=["-Due Date", "Title"], max_total=50, ): print(task["Title"]) ``` | Argument | Meaning | |---|---| | `where` | A condition built with `F`, or a query string | | `select` | Field labels, IDs, or field objects. Defaults to the table's default fields | | `order_by` | Field labels, IDs, or field objects. Prefix a label with `-`, or negate an ID, to sort descending | | `page_size` | Records to request per call to Quickbase. Defaults to 1,000 | | `max_total` | Stop after this many records in all, across pages | Record ID# is always selected, and it's always the last sort key so that pages stay stable. That means that without `order_by`, records come back in Record ID# order rather than the table's default sort. Nothing is fetched until your loop starts. Then `query()` fetches up to `page_size` records at a time, and fetches the next page only once your loop has used the last record of the current one. It stops when nothing more matches or `max_total` is reached, and the last request asks only for what's left. Breaking out of the loop fetches no more pages. Paging uses offsets, so if records are deleted while you read, others can be skipped without any error. Avoid deleting from the set you're reading, or read it all first. To count without fetching, use `count()`. Field objects such as `F("Status")` name exactly that field, so a leading `-` in one is part of the label. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#find-open-tasks), with simulated data. ### Fetching one page at a time `query_page()` fetches a single page, for when you control the paging yourself, such as behind a list endpoint. It takes the same arguments as `query()`, with `limit` and `skip` in place of `page_size` and `max_total`. ```python from pyquickbase import F skip = 0 while True: page = tasks.query_page(where=F("Status") == "Open", select=["Title"], limit=100, skip=skip) for task in page.records: print(task["Title"]) if not page.has_more: break skip = page.next_skip ``` | Attribute | Meaning | |---|---| | `records` | This page's records | | `total` | Every match, not just this page | | `skip` | The `skip` this page was fetched with | | `has_more` | Whether any matches come after this page | | `next_skip` | The `skip` that fetches the following page | Skipping past the end returns no records rather than raising an error, with `total` still correct. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#count-and-page), with simulated data. ### DataFrames `to_pandas()` and `to_polars()` fetch what a query matches into a DataFrame, with a column for each field it returns. They're on `query()`, a report's `run()`, and any page. Install the library you use with the SDK's extra, such as `pip install 'pyquickbase[pandas]'` or `pip install 'pyquickbase[polars]'`. ```python from pyquickbase import F frame = tasks.query(where=F("Status") == "Open", select=["Title", "Due Date"]).to_pandas() print(frame.columns.tolist()) # ['Record ID#', 'Title', 'Due Date'] ``` - Dates, timestamps, numbers, and lists keep their types, so a timestamp column is timezone-aware. - A user field holds the user's email, or their ID if the client's policy hides emails, and a file field holds the latest version's file name. Use `query()` for the `User` and `FileAttachment` objects. - A query with no matches still gives a DataFrame with its columns. - It reads every page first, so set `max_total` on a large table. To write a DataFrame back, pass it to `insert()`, `update()`, or `upsert()`, as [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/#writing-a-dataframe) shows. ### Reading values Read values by label or ID. `dict(record)` gives you `{label: value}`. ```python task = next(iter(tasks.query(max_total=1))) print(task.id) print(task["Status"], task[7]) print(dict(task)) ``` Date fields come back as `date` objects and timestamp fields as timezone-aware `datetime` objects, or `None` when empty. Other values are as Quickbase returns them, so a user field is a dictionary and a numeric field is a `float`. [Field types](https://matthew-williams-solutions.github.io/pyquickbase/field-types/) lists what every type reads as, including when it's empty. **A record only holds the fields you selected.** Without `select`, that's the table's default fields, so a built-in field such as `Record Owner` may be missing. Reading a missing field raises `KeyError`, and `3 in record` checks for one first. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#read-values), with simulated data. ### Summarizing records `summarize()` counts records and finds totals, without needing a report built in the UI. Pass the fields to group by, and what to calculate. Every result is keyed by field label. ```python from pyquickbase import F, Period rows = tasks.summarize( group_by=["Status", Period("Due Date", "month")], where=F("Project") == "Roof", sum="Amount", avg="Amount", max="Due Date", ) for row in rows: print(row.group["Status"], row.group["Due Date"], row.count, row.sum["Amount"]) ``` Leave out `group_by` for one row covering every matching record: ```python overall = tasks.summarize(sum="Amount", where=F("Status") == "Open")[0] print(overall.count, overall.sum["Amount"]) ``` | `SummaryRow` attribute | What it holds | |---|---| | `group` | The values that define the group. Empty without `group_by`. A user is their email, and an empty value is `None` | | `count` | How many records are in the group, including those with empty values | | `sum` | The sum of each field, which is `0` if every value is empty | | `avg`, `median`, `min`, `max` | Each field's result, or `None` if every value is empty. Empty values are left out | | `std_dev` | Each field's sample standard deviation, or `None` with fewer than two values | | `distinct_count` | How many different values each field has, leaving out empty ones | | `change` | With `compare_periods`, how the group changed since the period before | Groups are sorted by their values, with empty ones last. If nothing matches, you get an empty list, or one row with a `count` of 0 without `group_by`. `sum`, `avg`, `median`, and `std_dev` take numeric fields. `min` and `max` also take dates, timestamps, and times of day. `distinct_count` takes any field you can group by, such as how many clients have open tasks. You can't group by a multi-select, multi-user, or file field, because a record can hold several of those values at once. `row.statistic("median")` reads any statistic by name. A median keeps every value of its field in memory until the summary ends, so set `max_records_scanned` on a large table. The others keep only running totals. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#add-up-time-across-tables), with simulated data. #### Grouping dates by period `Period` groups a date or timestamp field by `day`, `week`, `month`, `quarter`, or `year`, labeled `2026-03-14`, `2026-W11`, `2026-03`, `2026-Q1`, and `2026`, so they sort in order. Which day a timestamp falls on depends on the time zone, so pass `tz` if you don't want UTC: ```python from zoneinfo import ZoneInfo from pyquickbase import Period by_week = Period("Date Created", "week", tz=ZoneInfo("America/New_York")) print(tasks.summarize(group_by=by_week)) ``` #### Comparing each period with the one before With `compare_periods=True`, each row's `change` says how its group changed since the period before, so you don't have to line up the rows and subtract. `group_by` needs exactly one `Period`. Any other field in it stays fixed, so each owner's months are compared with their own. ```python from pyquickbase import Period rows = tasks.summarize( group_by=[Period("Due Date", "month"), "Status"], sum="Amount", compare_periods=True, ) for row in rows: if row.change: print(row.group["Due Date"], row.change.previous, row.change.count, row.change.values) ``` | `PeriodChange` attribute | What it holds | |---|---| | `previous` | The period it's compared with, such as `2026-02` | | `count` | How many more records there are, negative for fewer | | `values` | How much each numeric statistic rose, as `{"sum": {"Amount": 120.0}}`, or `None` where either period has no value | A period with no records still counts: its count and `sum` are 0, and it has no average or median. The first period in the result has no `change`, since nothing before it was summarized. A filter that starts or ends partway through a period makes that period look smaller, so filter on whole periods. #### Large tables A summary that only counts, with no `group_by` or statistics, asks Quickbase how many records match, in one request, and reads none, so it takes the same time at any size. Any other summary reads every matching record, in pages of up to 25,000. A page holds fewer records when the summary reads more fields, so that it takes about 25 MB of memory however wide it is. Totalling one field of 250,000 records by month takes about 11 requests and 6 seconds. A report built in the UI does the calculating in Quickbase, and is faster still. See [Reading summary and chart reports](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/#reading-summary-and-chart-reports). To refuse a scan larger than you expect, set `max_records_scanned`. If more records match, it raises `TooManyRecordsError`, a `QueryError`, without reading them, and its `matched` and `limit` say by how much. A count isn't limited by it, as it reads no records. It never returns a total from only part of the data. ```python from pyquickbase import TooManyRecordsError try: rows = tasks.summarize(group_by="Status", max_records_scanned=50_000) except TooManyRecordsError as error: print(error.matched, error.limit) ``` Records can change between pages, so on a table in use, the result may not match any one moment. ### Running reports Find a report on its table by name or ID. Like tables, it's checked when first used. The API can't create reports, so build them in Quickbase. ```python print([(report.id, report.name, report.type) for report in tasks.reports()]) for row in tasks.report("Open tasks").run(max_total=50): print(row["Title"]) page = tasks.report("12").run_page(limit=100, skip=0) print(len(page.records), page.total, page.has_more) ``` Which method reads a report depends on its type, which `report.info().type` gives: | `report.info().type` | Read it with | Rows are | |---|---|---| | `table` | `run()` or `run_page()` | Records, as from `query()` | | `summary` or `chart` | `summary_rows()` | Keyed by column label | Calling the other method raises `UnsupportedReportError`, as does every method on a report of another type, or one whose `incomplete` is true, such as one that asks the user for a value. The report isn't run. The report decides the filter, sort, and columns: - **Record ID# may be missing.** `record.id` raises `KeyError` if the report doesn't include that column, and the default `List All` report doesn't. To check first, use `3 in record`, because `hasattr(record, "id")` raises `KeyError` too. - **Pages may not be stable.** `query()` adds Record ID# as the last sort key to keep pages stable, but a report's sort can't be changed. Ties stayed in Record ID# order in a 1,200-record test, but that isn't confirmed for larger tables or while records change. If it matters, prefer `query()`. - **Columns can be missing for restricted users.** A column the user's role can't see is left out with no error, so `record["Salary"]` raises `KeyError` for them. - **Report IDs are only unique within a table.** Different tables reuse the same IDs, so always get a report from its own table. - **Names can repeat.** A repeated name raises `UnknownReportError`, which lists the IDs to choose from. An ID takes priority over a name. Only table reports can be read as records. Summary, chart, and ask-the-user reports raise `UnsupportedReportError` before anything runs, because a summary's column IDs are positions rather than fields, so a total could read as a Record ID#. Running a report adds to its usage count in Quickbase, but changes no records. Report details come from the same cache as other metadata, so `usedCount` in `raw` can be out of date, and so can the details used to check that a report is a table report. Quickbase's UI doesn't let you change a saved report's type. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#run-reports), with simulated data. #### Reading summary and chart reports `summary_rows()` reads a report built in the UI as `{column label: value}` rows, with Quickbase doing the calculating. A summary report labels its columns with the kind of total, such as `Hours (tot)`. ```python rows = tasks.report("Hours by Project").summary_rows() for row in rows: print(row["Project"], row["Hours (tot)"]) ``` Values are what Quickbase returns, so unlike `run()`, dates aren't converted, and a chart's dates are text in the app's format, such as `01-01-2026`. A report that asks the user for a value can't run through the API at all, and a report with two columns of the same label raises `UnsupportedReportError`, as a row could only hold one of them. Each run adds to the report's usage count. ### When something is refused These all raise before the query is sent, though the SDK may read table metadata first to check them. | What happened | Exception | |---|---| | The app has no such table, or the table is in another app | `UnknownTableError` | | `select`, `order_by`, or `where` names a field the table doesn't have, or the token can't see | `UnknownFieldError` | | `where` can't be sent as written, such as a lowercase operator in a query string, or `_curuser_` on a user field through `F` | `QueryError` | | `where_from_dict()` got data it can't use, such as an unknown operator, a missing `value`, or `today` on a date field | `QueryError` | | `limit`, `page_size`, or `max_total` below 1, or a negative `skip` | `QueryError` | | `select` or `order_by` isn't a field or a list of them | `QueryError` | | `get()` with something other than a positive integer | `InvalidValueError` | | Reading a field the record doesn't hold | `KeyError` | | The table has no such report, or several with that name | `UnknownReportError` | | `run()` on a summary, chart, or ask-the-user report, or `summary_rows()` on a table or ask-the-user report, or one with two columns of the same label | `UnsupportedReportError` | | `summarize()` with a field it can't use, such as text in `sum`, or a multi-select field in `group_by` | `QueryError` | | `summarize()` finds more records than `max_records_scanned` | `TooManyRecordsError`, a `QueryError`, and no records are read | | `summarize()` finds a value that isn't what its field's type says | `InvalidResponseError`, and there's no partial result | See [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/) for failures that come from Quickbase, such as a refused token. ## Writing records Source: This page covers creating, updating, and deleting records, and what to do when rows fail. ```python from pyquickbase import Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") result = tasks.insert([{"Title": "Pour slab"}]) print(result.created_ids) ``` The other examples on this page use `tasks`, made in the same way, with the sync client. With the async client, use `await` on each call. | Method | What it does | |---|---| | `table.insert(records)` | Creates records, and never changes existing ones | | `table.update(records)` | Changes records. Each one needs a `Record ID#` | | `table.upsert(records, merge_on="Code")` | Updates the record whose unique field matches, or creates one | | `table.delete(where)` | Deletes matching records and returns the count | To choose between the three that write: use `update()` to change records you know by Record ID#, `upsert()` to update or create by a unique field, and `insert()` to only create. `insert()` refuses a table whose key field isn't Record ID#, so use `upsert()` with `merge_on` set to the key field there. ### Writing values Rows are dictionaries keyed by field label or ID. [Field types](https://matthew-williams-solutions.github.io/pyquickbase/field-types/) lists what to write for each type. | Python value | What happens | |---|---| | `date` | Written as a date | | `datetime` with a timezone | Written as a date and time, and reads back to the second. Without a timezone, it raises `InvalidValueError` | | `Decimal` | Written exactly, so money doesn't pick up rounding errors | | `str`, `int`, `float`, `bool`, `list`, or `dict` | Written as it is | | `None` | Clears the field | | `FileUpload` | Attaches a file. See [File attachments](https://matthew-williams-solutions.github.io/pyquickbase/guides/files/) | | Anything else | Raises `InvalidValueError` | ### Creating records ```python from datetime import date result = tasks.insert( [ {"Title": "Pour slab", "Status": "Not Started", "Due Date": date(2027, 1, 15)}, {"Title": "Frame walls", "Status": "Not Started"}, ] ) print(result.created_ids) ``` `result.row_ids` holds each row's new Record ID#, in the order of your rows, with None for a row that failed. `created_ids` leaves failed rows out, so it lines up with your rows only when none failed. A row that sets `Record ID#` raises `InvalidValueError`, because that would update a record. #### Tables with a custom key field A table's key field is `Record ID#` unless someone has changed it in the Quickbase UI. If they have, Quickbase [changes the record](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#writes) when a row has an existing value in that field, rather than creating one, and there's no way to ask it to only create. So `insert()` raises `InvalidValueError` on those tables, and sends nothing. Use `upsert()` instead, which updates a matching record or creates one, and tells you which it did. ```python print(tasks.info().key_field_id) # 3 is Record ID# result = tasks.upsert([{"Code": "A-100", "Title": "Pour slab"}], merge_on="Code") print(result.created_ids, result.updated_ids) ``` ### Updating records Each row needs a `Record ID#`. Fields you leave out keep their values. A Record ID# that doesn't exist is an error, not a new record. A record whose values didn't change is listed in `unchanged_ids`, not `updated_ids`. ```python result = tasks.update([{"Record ID#": 7, "Status": "Done", "Notes": None}]) print(result.updated_ids, result.unchanged_ids) ``` [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#change-records), with simulated data. ### Upserting records `merge_on` must name a field marked as unique, and every row must set it. A row whose value doesn't match any record **creates a new record**, so use `update()` if that isn't what you want. Repeating the same write changes nothing, and lists the record in `unchanged_ids`. ```python result = tasks.upsert([{"Code": "A1", "Title": "Frame walls"}], merge_on="Code") print(result.created_ids, result.updated_ids) ``` ### Writing a DataFrame `insert()`, `update()`, `upsert()`, and `preview_update()` also take a pandas or polars DataFrame, with a column named after each field, by label or ID. Each row is one record. ```python import pandas as pd frame = pd.DataFrame({"Title": ["Order chairs", "Book venue"], "Status": ["Open", "Open"]}) result = tasks.insert(frame) print(result.created_ids) ``` - A missing value, such as `NaN`, `NaT`, or `None`, leaves the field empty in `insert()`, and clears it in `update()` and `upsert()`. Drop the column to leave a field as it is. - The index isn't written. Use `reset_index()` to make it a column. - A timestamp needs a timezone, as any `datetime` does, so convert a naive column with `tz_localize()` first. - Rows are checked as dictionaries are, so a column that isn't a field raises `UnknownFieldError` and nothing is written. ### Deleting records `where` is required, and it's checked before sending. This matters more here than anywhere else: Quickbase treats a lowercase operator as "contains", so a typo could delete far more than you meant. If nothing matches, it returns 0 rather than raising an error. ```python from pyquickbase import F deleted = tasks.delete(F("Status") == "Cancelled") print(deleted) ``` [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#delete-records-safely), with simulated data. ### Previewing a write `preview_update()` and `preview_delete()` show what a write would do, and change nothing. They only read, so they work on a [read-only client](https://matthew-williams-solutions.github.io/pyquickbase/policies/#making-a-client-read-only). `preview_update()` takes the same rows as `update()`: ```python created = tasks.insert([{"Title": "Pour slab", "Status": "Open"}]).created_ids[0] preview = tasks.preview_update([{"Record ID#": created, "Status": "Done", "Title": "Pour slab"}]) for change in preview.changes: for label, values in change.fields.items(): print(change.record_id, label, values.before, "->", values.after) # Only Status changes print(preview.unchanged_ids, preview.missing_ids) ``` | Attribute | What it holds | |---|---| | `changes` | The records that would change, each with `{field label: FieldChange}`, which has `before` and `after` | | `unchanged_ids` | The records that already hold these values | | `missing_ids` | The records that don't exist, or that the token can't see | | `events` | Active webhooks, Quickbase actions, email notifications, and automations on the table, which the update may set off | An empty field reads as `None` in both `before` and `after`. A file field shows the file's name, and leaves out its contents and who uploaded it. If a record is in several rows, a later row wins, as it would in `update()`. A value counts as a change whenever the comparison isn't certain, so `update()` could still report a listed record as unchanged, but a preview doesn't leave out a change it can detect. `preview_delete()` takes the same `where` as `delete()`, checked the same way, and returns a page, so `total` is how many records would be deleted, however many it returns: ```python from pyquickbase import F page = tasks.preview_delete(F("Status") == "Cancelled", select=["Title"], limit=25) print(page.total, [task["Title"] for task in page.records]) ``` Both previews list the table's active webhooks, Quickbase actions, email notifications, and record-triggered automations in `events`, because changing records may set them off. Quickbase doesn't say which changes set each one off, so some may not run. `events` is `None` when they couldn't be read, such as when the token isn't an app administrator's. ```python from pyquickbase import F preview = tasks.preview_delete(F("Status") == "Cancelled") for event in preview.events or (): print(event.type, event.name or event.url) ``` A preview can't promise what the write will do: - **It's a snapshot.** A record can change between the preview and the write, as Quickbase has no transactions. Check `WriteResult` afterward. - **It doesn't check whether Quickbase will accept the write.** A formula field, a value outside a field's choices, one of the table's data rules, or a role that can't edit a field all look like a normal change. - **It doesn't check a client's policy.** A [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) can still refuse the write that follows. ### What a write returns `insert()`, `update()`, and `upsert()` return a `WriteResult`: | Attribute | Meaning | |---|---| | `created_ids` | Record IDs of new records | | `updated_ids` | Record IDs of records that changed | | `unchanged_ids` | Record IDs of records whose values were already the same | | `errors` | The rows that failed. This is only ever set on `WriteError.result`, because a failure raises | | `row_ids` | For `insert()`, each row's new Record ID#, or None for a row that failed | ### When rows fail Quickbase writes the rows it can and reports the rest, so a `WriteError` doesn't mean nothing changed. Its `result` lists both what was written and what failed, and `index` is the row's position in the list you passed. When a later batch of a large write fails, the error's `__cause__` is that batch's own error, such as a `RateLimitError`. ```python from pyquickbase import WriteError try: tasks.insert(rows) except WriteError as error: for row, record_id in zip(rows, error.result.row_ids): print(row, record_id) # None for a row that failed for failed in error.result.errors: print(rows[failed.index], failed.messages) ``` `reason` says why a row failed, without you having to read Quickbase's wording, which can change: | `reason` | Why the row failed | |---|---| | `duplicate` | A value is already in a unique field, which `field_id` names | | `permission` | The token's role refused it | | `invalid_choice` | A value isn't one of the field's choices, on a field with `allowNewChoices` off, and `field_id` names the field | | `rule` | One of the table's custom data rules refused it, and `messages` holds the rule's text | | `other` | Anything else, such as writing to a formula or built-in field, or wording this version doesn't recognize | This makes a repeated insert easy to treat as already done, for a table a role can add to but not change: ```python try: movements.insert([{"Key": "order-7", "Quantity": -1}]) except WriteError as error: if any(failed.reason != "duplicate" for failed in error.result.errors): raise ``` A write refused by the role raises `WritePermissionError`, whichever way Quickbase reported it. See [Errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/#refused-writes). **An upsert that matches an existing record needs permission to modify**, so a role that can only add gets a refusal when a write is repeated. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#an-import-that-loses-a-row), with simulated data. ### Rows Quickbase could skip A row needs at least one real value, so `insert()` and `upsert()` raise `InvalidValueError` for a row where every value is empty. Quickbase can skip such a row without saying so, which also shifts `created_ids` against your rows. ### Large writes Large writes are split into batches of 1,000 rows, which you can change with `batch_size`. If a later batch fails outright, `WriteError` is raised with the results so far. Check `error.result` before trying again, so you don't write the same rows twice. ```mermaid flowchart TD I["insert(2,500 rows)"] --> B1["Batch 1 is written
rows 1 to 1,000"] B1 --> B2["Batch 2 is written
rows 1,001 to 2,000"] B2 --> B3["Batch 3 fails
Refused: not written
Timed out: may be written"] B3 --> E["WriteError
error.result holds
batches 1 and 2"] ``` A write that times out, or gets a 502, 503, or 504, isn't retried, because Quickbase may already have applied it. If batch 1 fails, its own error is raised, such as `RateLimitError`, except a 403, which is still a `WritePermissionError`. ### When something is refused | What happened | Exception | What's left behind | |---|---|---| | A row sets `Record ID#` in `insert()`, or leaves it out of `update()` | `InvalidValueError` | Nothing was sent | | A row leaves out the `merge_on` field in `upsert()` | `InvalidValueError` | Nothing was sent | | A row can't be sent in `preview_update()`, for the same reasons as `update()` | `InvalidValueError` | Nothing was read | | `Record ID#` in `preview_update()` isn't a positive integer | `InvalidValueError` | Nothing was read | | `preview_delete()` with a `where` that's None or can't be sent as written | `QueryError` | Nothing was read | | A row has no real values in `insert()` or `upsert()` | `InvalidValueError` | Nothing was sent | | `insert()` on a table whose key field isn't `Record ID#` | `InvalidValueError` | Nothing was sent | | A value that can't be written, such as a `datetime` without a timezone | `InvalidValueError` | Nothing was sent | | A row names a field the table doesn't have, or the token can't see | `UnknownFieldError` | Nothing was sent | | `batch_size` below 1, or a `delete()` condition that can't be sent as written | `QueryError` | Nothing was sent | | The client is read-only, or its [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) doesn't allow this write | `PolicyRefusedError` | Nothing was sent, unless it's a later batch, below | | `merge_on` isn't a unique field | `BadRequestError` | Nothing was written | | Some rows failed, such as a duplicate or a formula field | `WriteError` | **The other rows were written** | | A later batch failed outright, or a [rule of your own](https://matthew-williams-solutions.github.io/pyquickbase/policies/custom-rules/) refused it | `WriteError` | **Earlier batches were written** | | The role refused the write | `WritePermissionError` | Check `error.result` for earlier rows | | The request timed out | `RequestTimeoutError` | **It may have been applied.** It isn't retried | See [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/) for how these exceptions relate. ## File attachments Source: This page covers uploading, downloading, listing, and deleting the files in file attachment fields. Every method here is also on the async client, where you `await` it. ### Uploading Set a file attachment field to a `FileUpload` in `insert()`, `update()`, or `upsert()`. ```python from pathlib import Path from pyquickbase import FileUpload, Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") upload = FileUpload("spec.pdf", Path("spec.pdf").read_bytes()) tasks.insert([{"Title": "Spec", "Attachment": upload}]) tasks.update([{"Record ID#": 7, "Attachment": upload}], timeout=120) ``` The other examples on this page use `tasks`, a table from a client made in the same way. - **A file field takes only a `FileUpload`.** Anything else raises `InvalidValueError`, including `None`, so leave the field out to keep it empty. Quickbase would otherwise accept these mistakes without an error: `None` or `""` changes nothing, and a value without a file name creates a record with no file. - **A `FileUpload` sent to any other field raises `InvalidValueError`**, because Quickbase would store the file's name and discard its contents. - **A file name can't contain a path or control characters.** Quickbase stores names as sent, and passes them on to whoever downloads the file. - `timeout` replaces the client's timeout for that call, for a large file or a slow connection. #### Versions **Each upload adds a version, and Quickbase silently removes the oldest once a field reaches its limit.** A new field keeps 3 versions, set by `maxVersions` in the field's properties. Uploading identical contents still adds a version, so repeating an upload can push out a version you wanted. | To keep | Pass to `create_field()` or `update_field()` | |---|---| | A different number | `properties={"maxVersions": 10}`, from 1 to 1,000 | | Every version | `properties={"versionMode": "keepallversions"}`, which ignores `maxVersions` | Lowering `maxVersions` removes nothing until the next upload, which then trims the field to the new limit. Raising it doesn't bring back versions already removed. #### Size limits Quickbase refuses any request body over 40 MiB, with a 413 that raises `APIError`. Base64 adds a third, so **the largest file you can upload is about 31.4 MB**. Writes are split into several requests to stay under this limit, whatever the `batch_size`, and a file too large to fit is left for Quickbase to refuse. If a later request fails, `WriteError` reports the rows already written. Files are held in memory, as bytes and as base64, while they're sent. ### Downloading `download_file()` returns the latest version, or the one you ask for. It works on a read-only client. ```python file = tasks.download_file(7, "Attachment") older = tasks.download_file(7, "Attachment", version=2, timeout=120) print(file.file_name, len(file.data), file.content_type) ``` `qb.request()` can't read these responses, because they aren't JSON. **Don't use `file.file_name` as a path.** Quickbase returns names exactly as they were uploaded, by anyone with access and through any tool, so a name can be `../../.bashrc`. Keep only the last part, and decide where the file goes yourself: ```python from pathlib import Path, PureWindowsPath name = PureWindowsPath(file.file_name).name # handles both / and \\ if name in ("", ".", ".."): name = "download" (Path("downloads") / name).write_bytes(file.data) ``` Likewise, `file.content_type` is based on the file's name, not its contents, and the contents are whatever was uploaded. Treat both as untrusted, especially before opening or serving a file. ### Listing versions Reading a file field gives a `FileAttachment`, or None when the field has no file. Its `versions` are `FileVersion` objects, oldest first, and `latest` is the newest. ```python task = tasks.get(7, select=["Attachment"]) if task["Attachment"] is not None: for version in task["Attachment"].versions: print(version.number, version.file_name, version.uploaded) ``` Each version names its uploader in `creator`, a `User` with their email address, so take care when logging these values or passing them on. A `User`'s `repr` leaves the email out, and a client's [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) leaves `creator` out unless a rule passes `user_emails=True`. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#read-users-tags-and-files), with simulated data. ### Deleting a version ```python tasks.delete_file_version(7, "Attachment", version=2) ``` `version` is required and must be at least 1, because Quickbase treats version 0 as the latest and would delete it. Deleting the only version leaves the field empty and keeps the record. Version numbers aren't reused or renumbered. ### When something is refused | What happened | Exception | What's left behind | |---|---|---| | A file field set to anything but a `FileUpload`, or a `FileUpload` sent to another field | `InvalidValueError` | Nothing was sent | | A file name with a path or control characters | `InvalidValueError` | The `FileUpload` wasn't created | | A record ID or version that isn't a positive integer, a `timeout` that isn't a positive number, or a field that isn't a file field | `InvalidValueError` | Nothing was sent | | A field label or ID that doesn't exist | `UnknownFieldError` | Nothing was sent | | A request over 40 MiB | `APIError`, status 413 | Nothing in that request was written. Earlier batches were, and a `WriteError` says so | | A record, version, or file that doesn't exist | `NotFoundError` | Nothing was changed | | A download from a field the role can't see | `NotFoundError` | Nothing was changed | | An upload the role can't do | `WritePermissionError` | Nothing in the refused rows changed | | Deleting a version the role can't delete | `PermissionDeniedError` | Nothing was changed | ## CSV and Excel files Source: This page covers exporting records to CSV and Excel files, and importing a file's rows into a table, with every value checked before anything is written. Every method here is also on the async client, where you `await` it. CSV needs nothing extra. Excel needs `openpyxl`, which `pip install 'pyquickbase[excel]'` installs. ### Exporting records `to_csv()` and `to_excel()` write what a query matches to a file, with a header row of field labels. They're on `query()`, a report's `run()`, and any page, and return how many records they wrote. ```python from pyquickbase import F open_now = tasks.query(where=F("Status") == "Open", select=["Title", "Due Date", "Amount"]) print(open_now.to_csv("open-tasks.csv")) tasks.query(select=["Title", "Amount"]).to_excel("tasks.xlsx", sheet="Tasks") ``` Records are written as they're fetched, a page at a time, so a large table never has to fit in memory. Record ID# is always a column, so a file you export can be imported back to update the same records. | Value | In a CSV | In Excel | |---|---|---| | Date | `2027-01-15` | An Excel date | | Timestamp | `2027-01-15T09:30:00+00:00` | An Excel date and time, in UTC, as Excel has no time zones | | Number | As Quickbase returns it | An Excel number | | Checkbox | `true` or `false` | `TRUE` or `FALSE` | | User | Their email, or their ID if the client's policy hides emails | The same | | Multi-select or multi-user | Values separated by `;` | The same | | File attachment | The latest version's file name | The same | **Text that a spreadsheet would run as a formula is made safe.** A value starting with `=`, `+`, `-`, or `@` can run when the file is opened in a spreadsheet, so `to_csv()` writes it with a leading `'`, which spreadsheets show without it, and `rows_from_file()` removes again. Pass `spreadsheet_safe=False` for a file only programs will read. In Excel, text is always stored as text, so nothing is added. `to_csv()` writes UTF-8 with a byte order mark, which Excel needs to show accented letters correctly. Pass `encoding="utf-8"` for a file without one. ### Importing a file `rows_from_file()` reads a CSV or Excel file and converts each value for its field. Nothing is written yet, so you can check what it found, and then pass it to `insert()`, `update()`, `upsert()`, or `preview_update()`. ```python from pathlib import Path Path("new-tasks.csv").write_text( 'Title,Due Date,Amount\nOrder chairs,2027-03-01,250\nBook venue,2027-03-15,"1,200"\n' ) rows = tasks.rows_from_file("new-tasks.csv") if rows.ok: result = tasks.insert(rows) print(result.created_ids) else: for error in rows.errors: print(error) # Row 3, column 'Due Date': isn't a date in the form YYYY-MM-DD... ``` The first row names each column's field by its exact label. A blank cell leaves a field empty in `insert()`, and clears it in `update()` and `upsert()`. A row whose cells are all blank is skipped. | `FileRows` attribute | What it holds | |---|---| | `rows` | Each row, as `{field label: value}`, ready to write | | `row_numbers` | Each row's number in the file, counting the header as row 1, as a spreadsheet does | | `errors` | Every value that can't be written, each with its `row`, `column`, and `message` | | `columns` | Which field each column was read into | | `skipped` | The columns that weren't read | **A write refuses a `FileRows` with errors, and writes nothing,** listing the first few. Every error in the file is found at once, so they can all be fixed before trying again. An error never repeats the value, which may be sensitive. #### How values are read Each value is converted by its field's type, because Quickbase quietly empties a number field given text it can't read as a number, such as `1,234.5` or `45%`. | Field type | Takes | |---|---| | Number, currency, rating | `1234.5`, `1,234.5`, or `-0.5` | | Percent | `45%` or `0.45`, which are the same | | Duration | Whole milliseconds, as Quickbase stores it | | Date | `2027-01-15`, or any form `date_format` gives, such as `%m/%d/%Y` | | Timestamp | ISO 8601, such as `2027-01-15T09:30:00+00:00`, or the form `datetime_format` gives. One without a time zone is read in `tz`, UTC by default | | Time of day | `13:30`, `13:30:00`, or `1:30 PM` | | Checkbox | `true`, `false`, `yes`, `no`, `1`, `0`, and a few more, ignoring case | | User | An email address or a user ID | | Multi-select or multi-user | Values separated by `;` | | Text | As written | Excel's own dates, times, numbers, and true or false values are used as they are. #### Columns that don't match A column that isn't a field raises `InvalidValueError` before any row is read, suggesting a close label if there is one. `columns` maps a column to a field, by label or ID, or skips it with `None`: ```python from pathlib import Path Path("tasks-export.csv").write_text("Task,Owner Notes,Amount\nPour slab,Call first,5\n") rows = tasks.rows_from_file("tasks-export.csv", columns={"Task": "Title", "Owner Notes": None}) print(rows.columns, rows.skipped) # {'Task': 'Title', 'Amount': 'Amount'} ('Owner Notes',) ``` A column for a field that can't be written, such as a formula, a lookup, a file, or Date Created, also raises an error. Pass `skip_read_only=True` to leave those columns out instead. #### Updating records from an export Export the fields to change, edit the file, and import it again. Record ID# says which record each row updates, and `preview_update()` shows what would change first: ```python tasks.query(select=["Title", "Amount"]).to_csv("amounts.csv") # ... edit amounts.csv in a spreadsheet ... rows = tasks.rows_from_file("amounts.csv") preview = tasks.preview_update(rows) print(len(preview.changes)) tasks.update(rows) ``` To match on another unique field instead, use `upsert()` with `merge_on`. #### Other formats - **Tab-separated files** are read when the path ends in `.tsv` or `.tab`. Pass `delimiter` for others. - **Excel** reads the first worksheet, or the one `sheet` names. A formula's last calculated value is read, which a file saved by Excel has, and a file made by another program may not. - **An open file** works too: text for CSV, and binary for Excel. `format` says which, when it can't be told. - **`.xls`**, Excel's older format, isn't supported. Save it as `.xlsx` or `.csv`. The whole file is read into memory, on a worker thread with the async client, so a large file doesn't stop the event loop. For a very large import, split the file, or read it in chunks with `pandas.read_csv(chunksize=...)` and pass each DataFrame to the write. #### Files you didn't make Set limits on a file someone else gave you. `max_rows` stops reading once the file has more rows with values than that, so a file with millions of rows is never held in memory. `max_unzipped_bytes` limits what an Excel file may unpack to, as it's a zip, and a small file can unpack to something huge. It defaults to 1 GB. Both raise `FileTooLargeError`, an `InvalidValueError`, with the `limit` it went over and its `unit`. ```python from pathlib import Path from pyquickbase import FileTooLargeError Path("upload.csv").write_text("Title\nOrder chairs\nBook venue\n") try: rows = tasks.rows_from_file("upload.csv", max_rows=1, max_unzipped_bytes=200_000_000) except FileTooLargeError as error: print(f"Over the limit of {error.limit} {error.unit}") ``` ### Writes that would have lost data Writing a record with a value Quickbase accepts but loses now raises `InvalidValueError`, whether it comes from a file or not: - Text that isn't a plain number, such as `"1,234.5"`, for a number field, which Quickbase leaves empty. Plain numbers as text, such as `"0.45"`, still work. - A user as `{"email": ...}`, which Quickbase ignores, leaving the field as it was. Write `{"id": ...}`, a `User`, or an email address. ## Formulas Source: This page covers running a formula without saving it, and creating formula fields. Every method here is also on the async client, where you `await` it. ### Running a formula `run_formula()` runs a formula against a table without saving anything. Pass `record_id` if the formula uses fields, and leave it out if it doesn't. It works on a read-only client. ```python from pyquickbase import Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") result = tasks.run_formula("[Amount] > 1", record_id=7) print(result.value) # True print(result.text) # 1 print(result.type) # bool print(tasks.run_formula("Today()").text) ``` The other examples on this page use `tasks`, a table from a client made in the same way. Quickbase always returns the result as a string, so `"0"` would count as true in Python. Use `result.value` unless you need the original text. | `type` | `value` | |---|---| | `bool` | `True` or `False`, or None if empty | | `number` | A `Decimal`, or None if empty | | `text` | The string | | `null` | None | | `date`, `date/time`, `timeofday`, `duration`, `textlist`, `user`, and anything else | The display text, unchanged | - **Dates and timestamps stay as text**, because they use the app's display format, and timestamps have no time zone: `2026-03-04T05:06:07Z` can come back as `03-03-2026 09:06 PM`. To get real dates, create a [formula field](https://matthew-williams-solutions.github.io/pyquickbase/guides/formulas/#formula-fields) and read it with `query()`, which returns `date` and `datetime` objects. - **Numbers are rounded for display**, so `1/3` returns `0.3333333`. A formula field keeps its full precision when you query it. - `Today()` follows Quickbase's date, not your computer's, which can differ near midnight. > **Warning: Don't run formula text you don't control** > > A formula can read other tables with `GetRecord()` and `GetRecords()`, whichever table you run it on, > and can return the realm and the token owner's email. Treat formula text from users or other systems > like code. A client with a [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) refuses > formulas, unless it's `Policy.read_only()`. > > It can't read a table in another app, even with a token that can reach both, or a table the token's > role has no access to. **That isn't an error:** the result is empty, as it is for a table that doesn't > exist, so an empty result doesn't mean there's no data. ### Formula fields Pass `formula` to `create_field()`. The field's type must match what the formula returns. ```python total = tasks.create_field("Total", "numeric", formula="[Amount] * 2") print(total.mode, total.formula) tasks.update_field("Total", formula="[Amount] * 3") tasks.ensure_field("Total", "numeric", formula="[Amount] * 3") # Changes nothing if it matches ``` Quickbase checks a field's formula when you save it, and values change straight away. Renaming a field updates the formulas that use it. #### Which types take a formula | Field type | With a formula | |---|---| | `text`, `numeric`, `currency`, `percent`, `rating`, `date`, `checkbox`, `duration`, `timeofday`, `url`, `email`, `phone`, `user`, `multitext`, `rich-text` | Works | | `datetime` | Works, and the field then reports its type as `timestamp` | | `timestamp` | Refused. Create the field as `datetime` instead | | `text-multi-line` and `multiuser` | Refused | ```python tasks.create_field("Checked", "datetime", formula="Now()") tasks.create_field("Next Day", "date", formula="[Due] + Days(1)") ``` ### When something is refused | What happened | Exception | What's left behind | |---|---|---| | A blank formula, to run or to save | `InvalidValueError` | Nothing was sent. Quickbase would otherwise empty a field's values | | A syntax error, unknown field or function, wrong result type, or `1/0` | `BadRequestError`, with Quickbase's explanation | Nothing was run, and a field keeps its old formula | | A formula that uses fields, run without `record_id` | `BadRequestError` | Nothing was run | | A `record_id` that doesn't exist | `NotFoundError` | Nothing was run | | A formula on a plain field, or on a type that doesn't take one | `BadRequestError` | The field is unchanged | | A formula using a field the role can't see | `PermissionDeniedError` | Nothing was run | | A client with a policy other than `Policy.read_only()` | `PolicyRefusedError` | Nothing was sent | | Writing to a formula field | `WriteError`, as a row error | The other rows were written | Quickbase's formula error messages can include HTML, such as a `javascript:` link, so escape them before showing them in a web page. ## Changing the schema Source: This page covers creating and changing tables, fields, and relationships. Every method here is also on the async client, where you `await` it. To stop a client from changing the schema, see [Limiting what a client can do](https://matthew-williams-solutions.github.io/pyquickbase/policies/). ### Creating a table and its fields `create_table()` returns the new table, and `create_field()` takes a label and a [field type](https://matthew-williams-solutions.github.io/pyquickbase/field-types/). ```python from pyquickbase import Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: app = qb.app("bq1234567") projects = app.create_table( "Projects", single_record_name="Project", plural_record_name="Projects" ) projects.create_field("Name", "text") projects.create_field("Status", "text-multiple-choice", choices=["Active", "Done"]) projects.create_field("Budget", "currency", properties={"decimalPlaces": 2}) projects.create_field("Score", "numeric", formula="1 + 1") ``` The other examples on this page use `qb`, a client made in the same way. - `choices` are the options of a `text-multiple-choice` or `multitext` field. Quickbase refuses them on other types. **They don't restrict what can be written** unless you also pass `properties={"allowNewChoices": False}`, because a new field accepts new choices by default. - `properties` are any other settings, sent as they are. The [field types page](https://matthew-williams-solutions.github.io/pyquickbase/field-types/#field-settings) lists common ones. - `formula` makes it a formula field. See [Formulas](https://matthew-williams-solutions.github.io/pyquickbase/guides/formulas/#formula-fields). - `help_text`, `bold`, `no_wrap`, `find_enabled`, `appears_by_default`, and `add_to_forms` are the settings Quickbase takes beside `properties`. See [Field settings](https://matthew-williams-solutions.github.io/pyquickbase/field-types/#field-settings). To run the same script more than once, use the `ensure_` methods in [Setup scripts you can run again](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/#setup-scripts-you-can-run-again). ### Making a field required or unique Quickbase sets `required` and `unique` in a second request, after creating the field. If that request fails, the field exists without them, so run `ensure_field()` to repair it once the problem is fixed. ```python tasks = qb.app("bq1234567").table("Tasks") tasks.create_field("Serial", "text", unique=True) tasks.update_field("Title", required=True) ``` What's already in the table decides whether Quickbase allows this: | Setting | When Quickbase refuses it | |---|---| | `required=True` | Any record has this field empty. **That includes every new field on a table with records**, so create the field, fill it in, and then make it required | | `unique=True` | The field already holds duplicate values. A new, empty field is allowed | Inserting a record without a required field's value then raises `WriteError`. ### Linking tables with a relationship Call `create_relationship()` on the child table and pass the parent. This example makes each task belong to a project. ```python app = qb.app("bq1234567") projects = app.table("Projects") tasks = app.table("Tasks") link = tasks.create_relationship(projects, foreign_key="Project", lookup_fields=["Name"]) ``` | Field | Table | What it is | |---|---|---| | `Project` | Child | The foreign key, a `numeric` field holding the parent's record ID | | `Project - Name` | Child | A lookup for each of `lookup_fields`, named after the foreign key and the field | | `Task records` | Parent | A list of the child's records, named after the child's record name | | `Add Task` | Parent | A link to add a child record | - `lookup_fields` are the parent's fields. `link.lookup_field_ids` lists the lookups that resulted, which are on the child, so don't pass one back in. - `tasks.relationships()` lists a relationship. `projects.relationships()` doesn't, because Quickbase only lists one on its child. - Calling `create_relationship()` again makes another relationship, even with the same label. `ensure_relationship()` finds the existing one. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#follow-links-between-tables), with simulated data. ### Adding lookups and summaries Use these three methods to add to a relationship, including one that already exists. Call each on the child table. - `ensure_lookup_field()` shows a parent's field on the child. - `ensure_summary_field()` calculates a value from the child's records, and puts it on the parent. - `ensure_count_field()` counts the child's records, and puts the count on the parent. ```mermaid erDiagram Projects ||--o{ Tasks : "has" Projects { recordid Record_ID PK text Name numeric Total_Hours "ensure_summary_field()" numeric Task_Count "ensure_count_field()" } Tasks { recordid Record_ID PK numeric Project FK "ensure_relationship()" text Project_Name "ensure_lookup_field()" numeric Hours } ``` Every method is called on the child, but a summary or count is created on the parent. ```python app = qb.app("bq1234567") projects = app.table("Projects") tasks = app.table("Tasks") link = tasks.ensure_relationship(projects, foreign_key="Project") tasks.ensure_lookup_field(link, "Name", label="Project Name") tasks.ensure_field("Hours", "numeric") tasks.ensure_summary_field(link, "Hours", "Total Hours") tasks.ensure_summary_field(link, "Hours", "Longest Task", function="max") tasks.ensure_count_field(link, "Task Count") projects.ensure_field("Hours Or Zero", "numeric", formula="Nz([Total Hours])") ``` `function` defaults to `sum`. Quickbase's own names, such as `STD-DEV`, work too, and a field's `summary_function` reads back in Quickbase's spelling: | `function` | Worked on | The summary's type | |---|---|---| | `sum` | `numeric`, `currency` | The same as the child's field | | `avg`, `std_dev` | `numeric` | `numeric` | | `max`, `min` | `numeric`, `date` | The same as the child's field | | `distinct_count` | `numeric`, `text` | `numeric` | | `combined_text` | `text` | `multitext` | | `combined_user` | `user` | `multiuser` | We tested these combinations, and others may work. Quickbase refused `sum` of a `checkbox` and `max` of `text`, with `BadRequestError`. `count` takes no field, so it has its own method, `ensure_count_field()`, and `ensure_summary_field()` raises `InvalidValueError` for it. **A summary with no child records is empty, not 0**, except a count, which is 0. Code that expects a number should read a formula that uses `Nz()`, such as `Hours Or Zero` in the example here, rather than the summary. #### Summarizing only some child records Pass `where` to either method to combine only the child records that match. It's a condition on the child table, built with `F` or written as a string, as in a query: ```python tasks.ensure_summary_field(link, "Hours", "Open Hours", where=F("Status") == "Open") tasks.ensure_count_field(link, "Done Tasks", where="{7.EX.'Done'}") ``` - **The filter is checked before anything is sent.** Quickbase saves a filter it can't use, such as one with a typo or a field that doesn't exist, without an error, and the summary then always reads as empty. The SDK raises `QueryError` instead. - **It's part of what makes a summary the same.** If a summary with that label exists with a different filter, or none, the method raises `SchemaMismatchError`. `Field.summary_query` holds a summary's filter. - **On a client with a policy,** the filter may only use fields the client can filter on, since the summary shows how many records match. A summary's filter is shown only when every field it uses is readable on the child, and then an existing summary is compared with it. Otherwise `summary_query` is None, and an existing summary is matched without its filter. - **A child with a row limit gets it in the filter.** The policy's `limit.rows()` for the child is ANDed into the filter, so the summary counts only what the client may reach, and an existing summary of that child whose filter doesn't hold the limit is hidden. - **Deleting a field a filter uses** stops the summary working. A plan lists it with the summaries that stop working. - `CreateSummaryField` takes the same `where`, as a string. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#watch-computed-fields-update), with simulated data. ### Setup scripts you can run again The `ensure_` methods return what's there and create only what's missing, so a script can stop partway and be run again. ```python app = qb.app("bq1234567") projects = app.ensure_table("Projects") projects.ensure_field("Name", "text") projects.ensure_field("Code", "text", unique=True) tasks = app.ensure_table("Tasks") tasks.ensure_field("Hours", "numeric") link = tasks.ensure_relationship(projects, foreign_key="Project") tasks.ensure_lookup_field(link, "Name", label="Project Name") tasks.ensure_summary_field(link, "Hours", "Hours Sum") projects.ensure_field("Total Hours", "numeric", formula="Nz([Hours Sum])") ``` | Method | Finds what's there by | If it exists | |---|---|---| | `ensure_table()` | Its name, ignoring case | Returns it unchanged | | `ensure_field()` | Its label, ignoring case | Changes only what you passed that differs, including `formula`. Raises `SchemaMismatchError` rather than changing its type | | `ensure_relationship()` | The parent, and the foreign key's label ignoring case | Returns it unchanged | | `ensure_lookup_field()` | The parent's field it points at, so a renamed lookup is still found | Applies `label` if you passed one | | `ensure_summary_field()` | Its label on the parent, ignoring case | Returns it. Raises `SchemaMismatchError` if that field isn't this same summary, with the same `where` | | `ensure_count_field()` | Its label on the parent, ignoring case | Returns it. Raises `SchemaMismatchError` if that field isn't a count of this relationship, with the same `where` | `required=True` in `ensure_field()` follows [the same rules as anywhere else](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/#making-a-field-required-or-unique), so a script that adds a required field to a table with records needs to fill it in first. ### Changing a field `update_field()` changes only what you pass, and takes a label or an ID. If Quickbase refuses, the field stays as it was. ```python tasks = qb.app("bq1234567").table("Tasks") tasks.update_field("Notes", label="Comments") tasks.update_field("Status", properties={"choices": ["Open", "Done", "Blocked"]}) ``` - `choices` in `properties` replaces the whole list, so include the ones you want to keep. - `formula` only works on a formula field, and can't be blank, because Quickbase would accept that and empty the field's values. - It can't change a field's type. Create a new field instead. ### Deleting fields and tables ```python tasks = qb.app("bq1234567").table("Tasks") tasks.delete_fields(["Old Field", "Older Field"]) tasks.drop(confirm="Tasks") ``` `delete_fields()` deletes the fields' data too. It raises `WriteError` if any field couldn't be deleted, such as a built-in field, **though the others in the list will have been**. If one doesn't exist, it raises `UnknownFieldError` and deletes nothing. `drop()` deletes the table and all its records, so it requires the table's exact name. Before deleting a field, `field_usage()` shows where Quickbase uses it, as its Usage page does: in reports, forms, formulas, pipelines, notifications, and more. ```python for usage in tasks.field_usage(["Old Field", "Older Field"]): print(usage.label, usage.describe()) # Old Field 2 reports and 1 default report ``` `describe()` puts the counts in words, `counts` holds them by kind, such as `{"reports": 2}`, and `total` adds them up. Leave out the fields to get every field in the table. Quickbase counts a field in the reports it makes for every table, so a new field already has a few uses. Only an app administrator's token can read these, as with the Usage page. A [plan](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/#planning-changes-before-making-them) that deletes a field lists these uses in its warnings. ### Planning changes before making them `preview_schema_changes()` checks a list of changes against the app as it is, and says what each would do without making it. `apply_schema_changes()` then makes them, in order, if the app hasn't changed since. ```python from pyquickbase import CreateField, CreateRelationship, CreateSummaryField, DeleteField app = qb.app("bq1234567") plan = app.preview_schema_changes( [ CreateField(table="Tasks", label="Due", type="date"), CreateRelationship(child_table="Tasks", parent_table="Projects", foreign_key="Project"), CreateSummaryField( child_table="Tasks", foreign_key="Project", label="Total Hours", child_field="Hours" ), DeleteField(table="Tasks", field="Old Notes"), ] ) for change in plan.changes: print(change.status, change.risk, change.summary, change.warnings) if not plan.blockers: app.apply_schema_changes(plan) ``` Each `PlannedChange` has: - **`status`:** `ready`, `already_done` if the app already has it, or `blocked`. - **`risk`:** `additive`, `modifying`, or `destructive`. - **`settings`:** each setting it would set, before and after. - **`warnings`:** what else it would do. For example, deleting a field lists how many records lose a value and which formulas, lookups, and summaries use it. - **`blockers`:** why it can't be applied. Examples are a label already in use, empty values on a field being made required, duplicates on one being made unique, or the client's own limits. A later change can use a table or field an earlier one makes, so a plan can create a table, add its fields, and link it to another. ```mermaid flowchart TD A["apply_schema_changes(plan)"] --> C["Checks the plan again
If the app changed, or a
change is now blocked:
an error, nothing made"] C --> M["Makes each change,
in order

SchemaChangeResult"] M --> F["If one fails
SchemaChangeError, with
what was made"] ``` **Applying is checked again.** `apply_schema_changes()` plans the same changes again first. If the tables and fields they touch have changed, it raises `SchemaMismatchError`. If anything blocks them, it raises `InvalidValueError`. Either way, nothing is sent. Record counts aren't part of that check, as they change all the time. **Quickbase can't undo a change.** If one fails, `apply_schema_changes()` raises `SchemaChangeError`, whose `result` lists the changes made, the one that failed, and the ones not attempted. A change already made counts as `already_done`, so planning the same list again shows only what's left. To give a model these steps as tools, see [Changing the schema from an agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/changes/#changing-the-schema-from-an-agent). ### When something is refused | What happened | Exception | What's left behind | |---|---|---| | Unknown field type, a label already in use, or `choices` on the wrong type | `BadRequestError` | Nothing was created | | An unknown key in `properties` | `BadRequestError` | Nothing was created or changed | | `required` or `unique` refused while creating a field | `BadRequestError` | **The field exists without them** | | `required` or `unique` refused while updating a field | `BadRequestError` | The field is unchanged | | `create_table()` with a name that's in use | `BadRequestError` | Nothing was created | | `ensure_field()` with a different type | `SchemaMismatchError` | The field is unchanged | | `ensure_summary_field()` with a label that's in use | `SchemaMismatchError` | The field is unchanged | | A field label or ID that doesn't exist | `UnknownFieldError` | Nothing was changed | | `update_field()` with nothing to change, `delete_fields([])`, or the wrong `confirm` | `InvalidValueError` | Nothing was sent | | `delete_fields()` with a field that can't be deleted | `WriteError` | The other fields were deleted | | `apply_schema_changes()` after the schema changed | `SchemaMismatchError` | Nothing was sent | | `apply_schema_changes()` with a plan that has blockers | `InvalidValueError` | Nothing was sent | | A change in `apply_schema_changes()` that fails | `SchemaChangeError` | **The changes before it were made**, as `result` lists | See [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/) for how these exceptions relate. ### The metadata cache These methods refresh the client's cached metadata for you. After changing the schema some other way, such as in the Quickbase interface, call `qb.clear_schema_cache()`. ## Working with apps Source: This page covers creating, copying, and deleting whole apps, and listing what runs in one. Every method here is also on the async client, where you `await` it. ### Creating an app `create_app()` makes an empty app and returns it, ready for [tables and fields](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/#creating-a-table-and-its-fields). ```python from pyquickbase import Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: app = qb.create_app("Field Jobs", description="Crews, sites, and tasks") tasks = app.create_table("Tasks") ``` The token's user needs permission to create apps in the realm. Quickbase allows several apps with the same name, so running this twice makes two apps. `assign_token`, on by default, lets the client's token reach the new app. A token limited to some apps can't otherwise reach it, so leave it on unless the token can reach every app. ### Copying an app `copy()` makes a new app with the same tables, fields, reports, and forms, such as a staging copy to try changes on. ```python staging = qb.app("bq1234567").copy("Jobs (staging)", keep_data=True) ``` | Argument | Meaning | |---|---| | `keep_data` | Copy the records too. Off by default | | `keep_files` | With `keep_data`, copy file attachments too | | `keep_users` | Give the copy's users the roles they have in the original. Otherwise the roles are copied with no one in them | | `assign_token` | Let the client's token reach the copy, as for `create_app()` | ### Deleting an app `drop()` deletes the app, with all its tables and records, and Quickbase can't undo it. It requires the app's exact name, so a wrong variable can't delete the wrong app. ```python staging.drop(confirm="Jobs (staging)") ``` ### What runs in an app `events()` lists the app's webhooks, Quickbase actions, email notifications, subscriptions, reminders, and the automations that records trigger. On a table, it lists that table's. ```python for event in qb.app("bq1234567").table("Tasks").events(): print(event.type, event.name, event.active) ``` Only an app administrator's token can list them. Quickbase leaves out scheduled automations, and doesn't name automations, so an automation has a `url` instead. A [preview of a write](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/#previewing-a-write) lists the active ones that changing records may set off. ### Clients with a policy A client with a [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) never creates, copies, or deletes an app, because a policy covers one app's tables. Copying with `keep_data` would also read every record. Use a client without a policy for these. `events()` still works, and lists only events on tables the policy shows, without their owners. ## Handling errors Source: This page covers what the SDK raises, what each error means for your data, and what's retried for you. Every exception this library defines derives from `QuickbaseError`, and the async client raises the same ones. ### Catching errors Catch the specific errors you can act on, and `QuickbaseError` for the rest. ```python from pyquickbase import ( AuthenticationError, F, QueryError, Quickbase, QuickbaseError, RateLimitError, ) with Quickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") try: print(tasks.count(F("Status") == "Open")) except QueryError as error: print("Fix the query:", error) except AuthenticationError: print("Check the token, and the app and table IDs") except RateLimitError as error: print(f"Still rate limited. Try again in {error.retry_after} seconds.") except QuickbaseError as error: print(error) ``` The other examples on this page use `tasks`, a table from a client made in the same way. Catching a class catches everything under it. `WritePermissionError` is under both `WriteError` and `PermissionDeniedError`, so either one catches a refused write: ```text QuickbaseError ├── ConfigurationError ├── QueryError ├── InvalidValueError ├── ConfirmationError ├── PolicyRefusedError ├── SchemaMismatchError ├── UnknownTableError ├── UnknownFieldError ├── UnknownReportError ├── UnsupportedReportError ├── NetworkError │ └── RequestTimeoutError ├── InvalidResponseError ├── WriteError │ ├── SchemaChangeError │ └── WritePermissionError └── APIError ├── BadRequestError ├── AuthenticationError ├── PermissionDeniedError │ └── WritePermissionError ├── NotFoundError ├── RateLimitError └── ServerError ``` The ten listed first, from `ConfigurationError` to `UnsupportedReportError`, mean the request wasn't sent, though the SDK may have read metadata or counted records first. `ConfigurationError`, `QueryError`, and `InvalidValueError` are also `ValueError`s, and the three `Unknown...Error`s are also `LookupError`s. Reading a missing key from a `Record` raises Python's `KeyError`, as any mapping does. That includes `record.id` on a report row without a Record ID# column. ### Mistakes caught before sending These mean the request wasn't sent, though the SDK may have read the table's metadata first to check it. | Exception | Cause | |---|---| | `ConfigurationError` | Invalid client settings, or missing environment variables | | `QueryError` | A query that can't be sent as written, such as a lowercase operator in a hand-written string | | `InvalidValueError` | A row, value, or file request that can't be sent | | `SchemaMismatchError` | An `ensure_` method found a field that isn't what it was asked for, or the schema changed since a plan was made | | `UnknownTableError` | No table with that name or ID in this app, or the client's policy hides it | | `UnknownFieldError` | No field with that label or ID in this table, or the token's role or the client's policy hides it | | `UnknownReportError` | No report with that name or ID on this table, several with that name, or one the role can't use | | `UnsupportedReportError` | A report that can't be read as records, such as a summary or chart | | `PolicyRefusedError` | The client's [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) doesn't allow this request | A `PolicyRefusedError`'s `decision` says what was refused, by name, which rule refused it, and what would allow it. Its message follows one form: `Refused by : . . (rule: ) Nothing was sent. To allow it: .` ```python from pyquickbase import QueryError try: tasks.delete("{7.ex.'Done'}") except QueryError as error: print(error) # Quickbase would read the lowercase operator as "contains" ``` [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#catch-mistakes), with simulated data. ### Writes that fail partway Quickbase writes the rows it can and reports the rest, so a `WriteError` doesn't mean nothing changed. Its `result` holds what was written and each failed row, and `result.row_ids` matches an insert's new records to your rows. ```python from pyquickbase import WriteError rows = [{"Title": "Order chairs"}, {"Title": "Book venue", "Status": "Finished"}] try: tasks.insert(rows) except WriteError as error: for row, record_id in zip(rows, error.result.row_ids): print(row["Title"], record_id) # None for a row that failed for failed in error.result.errors: print(failed.index, failed.reason, failed.messages) ``` When a later batch of a large write fails, the error's `__cause__` is that batch's own error, such as a `RateLimitError`, and `result` holds the batches before it. See [When rows fail](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/#when-rows-fail) for each row's `reason`. ### Refused writes Quickbase refuses a write in two ways, depending on the role. A role with neither add nor modify rights on the table gets a 403. Any other refusal, including a field the role can only view, is a row error in a successful response. `WritePermissionError` covers both. It's a `WriteError` and a `PermissionDeniedError`, so either one catches it, and its `status_code` is 403 or 207. If some rows failed for another reason too, you get a plain `WriteError`, so check each row's `reason`. A row with one field the role can't change is refused whole, so its other fields don't change either. ### Errors from Quickbase These derive from `APIError`, which has `status_code`, `method`, `path`, `message`, `description`, and `request_id`. Include `request_id` when reporting a problem to Quickbase support. | Exception | Status | |---|---| | `BadRequestError` | 400 | | `AuthenticationError` | 401 | | `PermissionDeniedError` | 403 | | `NotFoundError` | 404 | | `RateLimitError` | 429, with `retry_after` | | `ServerError` | 5xx | > **Note: A missing app is a 401** > > Quickbase answers with a 401 rather than a 404 for an app or table that doesn't exist, or that the > token can't reach, with the same wording it uses for a bad token. So `AuthenticationError` can mean > any of these, and its message says to check the ID too. A bad realm or app can also return a 400. > **Warning: Messages can contain HTML** > > Quickbase's formula error messages can include HTML, such as a `javascript:` link, and errors pass > them on unchanged. Escape them before showing them in a web page. ### When a write may have landed These errors don't say whether Quickbase applied a write, so check the data before trying again. | Exception | Cause | |---|---| | `NetworkError` | The request failed before a response arrived | | `RequestTimeoutError` | The request timed out, a kind of `NetworkError` | | `ServerError` | Quickbase failed while handling it | | `InvalidResponseError` | Quickbase reported success, but the response wasn't in the expected format | ### Retries The client retries these for you, so an error you catch means the retries ran out. | Failure | Reads | Writes | |---|---|---| | 429, rate limited, honoring `Retry-After` | Yes | Yes | | Couldn't connect | Yes | Yes, as nothing was sent | | A timeout or dropped connection after sending | Yes | No, as it may have been applied | | 502, 503, or 504 | Yes | No | Reads are GET requests, record queries, report runs, and formula runs. A long burst of requests can still use up a call's retries and raise `RateLimitError`, so handle it if you make many calls at once. See [Configuring clients](https://matthew-williams-solutions.github.io/pyquickbase/guides/clients/#retries) to change the retries. ### Errors for a model `tool_error()` turns any of these into plain data with a fixed `code`, whether any data changed, and whether it's `retryable`. See [Returning results to a model](https://matthew-williams-solutions.github.io/pyquickbase/agents/results/#returning-results-to-a-model). ## Configuring clients Source: This page covers choosing a client, limiting what it can read and write, retries, and connection settings. `Quickbase` and `AsyncQuickbase` take the same options. Use them as context managers so that connections get closed. ### Sync or async ```python import asyncio from pyquickbase import AsyncQuickbase, F, Quickbase with Quickbase(realm="acme", token="your_user_token") as qb: print(qb.app("bq1234567").table("Tasks").count(F("Status") == "Open")) async def main() -> None: async with AsyncQuickbase(realm="acme", token="your_user_token") as qb: tasks = qb.app("bq1234567").table("Tasks") async for task in tasks.query(where=F("Status") == "Open", max_total=5): print(task["Title"]) asyncio.run(main()) ``` The clients have the same methods, and the sync client is a true sync client rather than a wrapper around asyncio. That means it works in notebooks, in threads, and inside a running event loop. One sync client can be shared across threads. The async client works with both asyncio and Trio. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#use-the-async-client), with simulated data. ### Using several tokens `Quickbase.from_env()` reads `QB_REALM` and `QB_USER_TOKEN`. A program with more than one token can name the variable, such as a setup token and a restricted one: ```python qb = Quickbase.from_env(token_env="QB_SERVICE_TOKEN") ``` ### Limiting what a client can do A client takes one `policy`, which says what it may read and change, such as `Policy.read_only()`. Every request is checked before it's sent. See [Policies](https://matthew-williams-solutions.github.io/pyquickbase/policies/) for how to write one, and what it does and doesn't protect against. ### Retries ```python from pyquickbase import Quickbase, RetryPolicy policy = RetryPolicy(max_retries=2, base_delay=0.5, max_delay=10, max_retry_after=30) qb = Quickbase(realm="acme", token="your_user_token", retry=policy) ``` `max_retry_after` is the longest `Retry-After` the client will wait for. Past that, it raises `RateLimitError`. A missing `Retry-After`, or one of 0, uses the backoff from `base_delay` instead, because Quickbase can send 0 while still limiting. Use `max_retries=0` to disable retrying. Each retry counts toward `max_retries`. Once they're used up, the last error is raised. What's retried: | Failure | Reads | Writes | |---|---|---| | 429, rate limited | Yes | Yes | | Couldn't connect, or no free connection in the pool | Yes | Yes, as nothing was sent | | A timeout or dropped connection after sending | Yes | No, as it may have been applied | | 502, 503, or 504 | Yes | No | | Any other error status | No | No | Reads are GET requests, record queries, report runs, and formula runs. Running a report again adds to its usage count again. `timeout` applies to each step of a request separately, connecting, sending, and waiting for each part of the response, as httpx does, rather than to the whole request. ### Using another API host `base_url` sets the API root, which is `https://api.quickbase.com/v1` by default. The right host for EU realms hasn't been confirmed, so pass `base_url` if yours differs. It must use HTTPS, except for loopback addresses. ### Proxies, TLS, and logging Pass your own [httpx](https://www.python-httpx.org/) client for proxies, custom TLS, event hooks, or custom transports. You remain responsible for closing it. ```python import httpx from pyquickbase import Quickbase http_client = httpx.Client(proxy="http://proxy.internal:8080") qb = Quickbase( realm="acme", token="your_user_token", user_agent="my-app/1.0", http_client=http_client, ) ``` Tokens are kept out of `repr`s, exceptions, and logs, and the client refuses to send them over plain HTTP, except to your own machine. ### Calling any endpoint For endpoints without a dedicated method, `request()` returns the decoded JSON body. ```python reports = qb.request("GET", "/reports", params={"tableId": "bq7654321"}) ``` ## Using pyquickbase from an AI agent Source: This guide is for developers who let an AI agent use the SDK, and for agents reading these docs. An agent can write a query or a delete from text it didn't choose, such as a user's message or a record it just read. The raw REST API accepts many of the resulting mistakes without an error. `DELETE /records` treats a lowercase operator as "contains", and a value of `_FID_6` in `{6.EX.'_FID_6'}` [deleted every record](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#query-pitfalls) in a test table. The SDK catches those mistakes, but what an agent may do is up to you. The docs are also available as [llms.txt](https://matthew-williams-solutions.github.io/pyquickbase/llms.txt), a list of the pages, and [llms-full.txt](https://matthew-williams-solutions.github.io/pyquickbase/llms-full.txt), all of them in one file. ### A safe starting point Give the agent small functions rather than the client. Your code fixes the table, fields, and operators, and the model only supplies values. ```python from pyquickbase import F, Policy, Quickbase def open_tasks(project: str) -> list[dict]: """A tool for the agent, which only chooses `project`.""" if not project.strip(): raise ValueError("A project is required.") with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") where = (F("Project") == project) & (F("Status") == "Open") return [dict(task) for task in tasks.query(where=where, select=["Title"], max_total=50)] ``` ### Limiting access Give an agent a client with a [policy](https://matthew-williams-solutions.github.io/pyquickbase/policies/) that allows only what its job needs. `Policy.read_only()` is the place to start, and it only sends GET requests, record queries, report runs, and formula runs. If the agent also needs to write, or shouldn't see everything the token can, write a `Policy` and bind it to the app. Anything it doesn't allow raises `PolicyRefusedError` and is never sent, which also applies to `qb.request()`. The SDK enforces it itself, so it doesn't depend on the model following its instructions. ```python from pyquickbase import Policy, Quickbase, allow, deny, fields, tables policy = Policy( classes={"internal": ["Tasks.Amount", "Tasks.Code", fields.labeled_like("Old*")]}, rules=[ # It can read everything but costs and codes, 50 records at a time, add tasks, and # change two of their fields. It can't delete anything. allow.read(tables.all(), max_paging_depth=50), allow.insert("Tasks", fields.all()), allow.update("Tasks", ["Status", "Notes"]), deny.all(fields.classed("internal")), ], ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) agent_client = Quickbase.from_env(policy=bound) ``` To the agent, a field or table the policy hides looks as if it doesn't exist. `table.fields()` leaves it out, and naming it raises `UnknownFieldError`. Anything else it refuses raises `PolicyRefusedError`, whose message says what was refused, by name, which rule refused it, and what would allow it, without the values it sent. Return either one to the model as the tool's result, with [`tool_error()`](https://matthew-williams-solutions.github.io/pyquickbase/agents/results/#errors). - **A class carries over to what's built from it.** A formula, lookup, or summary of a field in a class is in the class too, so denying the class hides them all, whatever table they're on. - **Fields added after binding are read-only,** and follow the policy's `new_fields`, until you bind again. - **`limit.rows()` keeps it to some records,** by ANDing a condition onto every query, summary, and delete the client sends. It's defense in depth behind the role's record-level rules. - **Email addresses are left out** of user values, and the uploader's details out of file values, unless a rule that reads the table passes `user_emails=True`. - **Formulas are refused** unless the policy is `Policy.read_only()`, because a formula can read any table and field. You can add checks of your own, such as a limit on how many records one call can change, with a [rule of your own](https://matthew-williams-solutions.github.io/pyquickbase/policies/custom-rules/), which can only refuse. See [what a policy doesn't do](https://matthew-williams-solutions.github.io/pyquickbase/policies/limits/#from-an-agents-side), including what these limits can't stop an agent doing. ### The token is the real boundary A token has its user's permissions, through their role, so Quickbase enforces that limit whichever code is running. Each layer checks a request on its own, so a mistake or a manipulated model has to get past all of them: ```mermaid flowchart TD subgraph proc["Your process"] M[Model] -- tool call --> T["Your tool
fixes the table,
fields, and scope"] T --> P["Client's policy
or PolicyRefusedError,
nothing sent"] end subgraph qb["Quickbase"] R["Token's role
or refused,
or the data is hidden"] --> A[(App data)] end P --> R O["Any other code
with the token"] -.-> R ``` The first two run in your process, and the role is enforced by Quickbase, whichever code is running. - Use a token whose user has a role with only the access the agent needs, and give the token access to as few apps as possible. Use a sandbox app while you're trying things out. - Use `Quickbase.from_env()` rather than putting a token in code or a prompt. This keeps it away from the model, but not from code the agent runs in the same environment. - Temporary tokens are deliberately [not in the SDK](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#temporary-tokens). Test the role you plan to use, because this has only been partly confirmed. A [view-only role](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#restricted-users) was refused every write we tried, and fields it couldn't see were left out. A [role with write access](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#roles-with-write-access) was held to its limits as well, and a formula can't read a table its [role has no access to](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#restricted-users). That result is empty, not an error, so an empty result doesn't mean there's no data. [Record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules) held too, for reads, formulas, and writes, but they only check a record before a change. A role can move a record it may modify to where it can no longer see it, and can add a record it won't be able to read. ## Untrusted input Source: ### Untrusted values Use [`F`](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/#writing-queries) for a value a model or user supplied, or [`where_from_dict()`](https://matthew-williams-solutions.github.io/pyquickbase/agents/untrusted-input/#filters-from-a-model) if they also choose the filter, and never build a `where` string from it. The SDK can't tell an added condition from one you meant to write, so a `code` of `zz'}OR{3.GT.'0` makes the second line below [match every record](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#query-pitfalls): ```python tasks.delete(F("Code") == code) # Raises QueryError for that value tasks.delete(f"{{6.EX.'{code}'}}") # Don't do this ``` `F` escapes quotes and backslashes, and handles the text that Quickbase would read as query syntax: - A value containing `{` or `}` raises `QueryError`. - A value like `_FID_6` raises `QueryError`. - A standalone uppercase `OR` or `AND` is sent in lowercase, so `a OR b` finds that text rather than matching `a` and `b`. Comparisons ignore case, so it still finds a stored `a OR b`. - Anything other than text, a number, a date, a boolean, or None raises `QueryError`, and so does a `datetime` without a timezone. That protects the shape of a query, not its meaning: - **Empty values match empty fields.** `F("Code") == ""` and `F("Code") == None` match every record with no code, so check that a value isn't empty first, especially before a delete. - **A model that chooses the field or operator chooses the result.** `F("Status") != "x"` is a valid query that matches almost everything. Fix these in your code. - **`delete()` requires `where`,** so leaving it out can't delete everything, but a broad condition still can. Call [`preview_delete()`](https://matthew-williams-solutions.github.io/pyquickbase/agents/changes/#checking-a-change-before-making-it) with the same condition first, and compare its `total` with what you expect. > **Note: `_curuser_` and relative dates** > > On a `user` or `multiuser` field, Quickbase reads `_curuser_` as the token's owner, ignoring case > and surrounding spaces, so a value from someone else could match the owner's records. `F` raises > `QueryError` for it there. On a text field it's literal text, so `F` allows it. > > On a date field, Quickbase reads text such as `today` or `1 day ago` as a date. That gives a > model nothing it couldn't get by naming the date, but text it can't read matches nothing, with no > error. Convert dates to `date` objects, so a bad one fails right away. [Try it in the playground](https://matthew-williams-solutions.github.io/pyquickbase/playground-app/index.html#search-with-text-you-dont-control), with simulated data. ### Filters from a model When a model needs to choose its own filters, have it send JSON, and don't let it write query strings. For a tool that runs one table operation, [`table.operation()`](https://matthew-williams-solutions.github.io/pyquickbase/agents/operations/#giving-a-model-operations) does this for you, with the schema and the check built together. Use these two directly for anything else, such as a web form's filters. This works the same with MCP and any other LLM tool system, because they all describe a tool's input with JSON Schema. `where_schema()` provides that description, and `where_from_dict()` checks what comes back. ```python from pyquickbase import F, Policy, Quickbase, where_from_dict, where_schema FILTERABLE = ["Status", "Due Date", "Amount"] SEARCH_TOOL_INPUT = { "type": "object", "properties": {"where": where_schema(fields=FILTERABLE)}, "required": ["where"], } def search_tasks(project: str, where: dict) -> list[dict]: """The tool. Your code fixes the table, the project, and the fields it may filter on.""" with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") condition = (F("Project") == project) & where_from_dict(where, fields=FILTERABLE) return [dict(task) for task in tasks.query(where=condition, select=["Title"], max_total=50)] ``` The model only supplies part of the filter. Your code fixes the rest, and a policy adds its own: ```mermaid flowchart TD M["Model
its filter"] --> AND((AND)) S["Your code
the scope"] --> AND P["Policy
limit.rows()"] --> AND AND --> Q["The query sent
to Quickbase"] ``` Return a `QueryError` or `UnknownFieldError` to the model as the tool's result, with [`tool_error()`](https://matthew-williams-solutions.github.io/pyquickbase/agents/results/#errors). Their messages say where the mistake is and what's allowed, so it can correct itself, and they don't repeat the values it sent. Nothing has been sent to Quickbase at that point. `where_from_dict()` refuses these rather than guessing what was meant: | Refused | Because | |---|---| | An unknown key or operator, or a condition without `value` | A dropped key would otherwise become a search for empty fields. Send `null` to mean that | | A string for `in`, such as `"abc"` | It would search for `a`, `b`, and `c` | | An empty value with anything but `==`, `!=`, `in`, or `not_in`, or a value of only whitespace | What these match isn't clear | | Text on a date, such as `today`, or a time without a time zone | Quickbase matches nothing, with no error, for text it can't read as a date | | Anything but a number on a number, and anything but true or false on a checkbox | The query would run, and mean something else | | `IR` and `XIR` | They take date ranges as text. Compare with dates instead | | Over 100 conditions, counting each value of `in` and `not_in`, or groups over 20 levels deep | A filter shouldn't need them, and they cost memory and time | | Whatever `F` refuses, such as `_FID_6`, braces, and `_curuser_` on a user field | Quickbase reads these as query syntax | What it can't do is decide whether a filter is the right one: - **Fix the scope in your code.** Combine it with `F`, as above, so that every query stays inside the project. Without that, a filter of `Record ID# > 0` matches every record, as it should. - **Pass `fields` to limit what it can filter on.** Otherwise a model can learn the value of a field you didn't select, by filtering on it and seeing which records come back. `where_schema()` only tells the model the list, so pass the same list to `where_from_dict()`, which enforces it. A client's policy enforces what it may filter on as well, on every query. - **Keep the schema small.** A tool's schema goes to the model with every request. The default covers `A and (B or C)` at about 700 tokens, and `max_depth=1` takes it to about 450 if a tool doesn't need that. Offering a whole table's labels adds to it, since they're repeated in each copy of a condition, so list only the fields a tool needs. - **Treat labels as untrusted.** Passing `fields` shows their labels to the model, and a label can hold any text, so only offer fields from an app you trust. ## Giving a model operations Source: ### Giving a model operations `table.operation()` turns one table operation into a tool for a model: a JSON Schema of the arguments it may send, and `run()`, which checks them against the same lists before calling the table. They're built together, so the model is offered exactly what `run()` accepts. Give each tool its own operation, and the model can search, count, change, and read records without you writing each call by hand. ```python from pyquickbase import AsyncQuickbase, F, QuickbaseError, tool_error, tool_result async def make_search(qb: AsyncQuickbase): tasks = qb.app("bq1234567").table("Tasks") return await tasks.operation( "query", fields=["Title", "Status", "Technician", "Due Date"], field_descriptions={"Technician": "ID of the assigned technician."}, max_records_per_call=50, ) # At startup: search = await make_search(qb) # The tool's definition: {"name": "search_tasks", "input_schema": search.schema} async def search_tasks(search, project: str, arguments: dict) -> dict: """The tool. The model sends `arguments`, and your code fixes the project.""" try: page = await search.run(arguments, scope=F("Project") == project) return tool_result(page, compact=True) except QuickbaseError as error: return tool_error(error) ``` The schema goes in your framework's tool definition, under the name it uses, such as `input_schema` or `parameters`. It's plain JSON Schema with no `$ref`, and a new dictionary each time, so you can add to it. Each call to `run()` goes through these steps, and stops with an error at the first one that fails. Only the last step changes anything: 1. Normalize the spellings. 2. Check keys, fields, and values. 3. Apply the scope. For an `insert`, `update`, or `preview_update`, this may read the table's fields, which are usually cached. 4. For a delete, count what it matches, and refuse it if that's more than `max_records_per_call`. *One count query.* 5. When there's a scope and the call names records (`update`, `preview_update`, `run_formula`, `download_file`, or `delete_file_version`), check those records are in it. *One count query.* 6. With `require_preview`, check the confirmation. For a delete, this reuses the count from step 4. 7. Call the table method. #### What each operation takes | Operation | The model sends | Runs | Returns | |---|---|---|---| | `query` | `where`, `select`, `order_by`, `limit`, `skip`. Without `select`, the table's default fields, or only the first `max_default_fields` of them | `query_page()` | `Page`, which `tool_result()` shows with `fields_left_out` when it left some out | | `count` | `where` | `count()` | An `int`, which `tool_result()` shows as `count` | | `summarize` | `where`, `group_by`, `sum`, `avg`, `min`, `max`. `total`, `average`, `minimum`, and `maximum` are read too. `order_by` sorts the groups, by `count`, a `group` field, or a calculated value, and `limit` and `skip` page through them | `summarize()`, then sorts and pages its groups | The page of `SummaryRow`, which reads as a list, and which `tool_result()` shows with `page` | | `insert` | `records` | `insert()` | `WriteResult` | | `update` | `records`, each with `Record ID#` | `update()` | `WriteResult` | | `delete` | `where`, which is required. It's refused if it matches more than `max_records_per_call` | `delete()` | An `int`, which `tool_result()` shows as `deleted_count` | | `preview_update` | As `update` | `preview_update()` | `UpdatePreview` | | `preview_delete` | `where`, `select`, `limit`. Give it the same `max_records_per_call` as `delete`, and `tool_result()` lists a blocker when a delete would be refused | `preview_delete()` | `Page` | | `run_report` | `report`, as its ID, its ID as a number, or its name, and `limit`, `skip` | `Report.run_page()` | `Page` | | `run_summary_report` | `report`, as for `run_report`, and `limit` | `Report.summary_rows()` | A list of rows | | `run_formula` | `record_id` | `run_formula()`, with the formula your code gives | `FormulaResult` | | `download_file` | `record_id`, `field`, `version` | `download_file()` | `FileDownload` | | `delete_file_version` | `record_id`, `field`, `version` | `delete_file_version()` | None | `where` is the format of [`where_from_dict()`](https://matthew-williams-solutions.github.io/pyquickbase/agents/untrusted-input/#filters-from-a-model). `order_by` is a list of `{"field": ..., "direction": "asc" or "desc"}`, and `group_by` takes labels, or `{"field": ..., "unit": "month"}` to group a date. Whatever `run()` returns, `tool_result()` takes. The schema lists one spelling of each value, but `run()` also reads the ones models tend to write, each as the one value it means: `starts with` or `startsWith` for `starts_with`, `eq` or `<>` for `==` or `!=`, `and` and `or` for `all` and `any`, `sort` for `order_by`, and `"order": "DESC"` or `"descending": true` for `"direction": "desc"`. Labels are never guessed, so a label in the wrong case is still refused, with a hint that names the right one. #### What a schema offers The schema lists only what the client may do, so a model isn't offered a field that would be refused: | Part | Offers | Limited by | |---|---|---| | `where` | Fields it may filter on | `allow.read()` or `allow.filter()` | | `select`, `group_by`, totals | Fields it may return | `allow.read()` | | `order_by` | Fields it may sort by | `allow.read()` or `allow.sort()` | | `limit` | Up to `max_records_per_call`, 100 by default | The policy's `max_paging_depth`, if lower | | `records` | Fields the SDK can write, other than formula, lookup, summary, built-in, and user fields | `allow.insert()` or `allow.update()` | | `report` | Table reports, or summary and chart reports, that can run through the API, by ID. `run()` also takes an ID as a number or a report's exact name, ignoring case, and says which operation runs a report of the other kind | `allow.report()`, and `reports=` | | `field`, for files | File fields | `allow.download()` or `allow.delete_file()` | `fields=` narrows all of these to the fields you list. An operation that the client can't run at all, such as `insert` on a read-only client, raises `PolicyRefusedError` when you make it, rather than giving the model a tool that always fails. Each list comes from `BoundPolicy.offers()`, which asks the policy about one field and verb at a time, so what's offered is exactly what's allowed. A rule of your own is asked too, so one that decides each field on its own is offered exactly what it allows. #### Writing values from JSON `run()` turns each value into what the write takes, by the field's type, and refuses anything else: | Field type | The model sends | |---|---| | Text, email, URL, phone, predecessor | Text. A multiple-choice field that doesn't allow new choices offers only its choices | | Number, currency, rating | A number | | Percent | A fraction, so 50% is `0.5`, in writes and filters alike | | Duration | Whole milliseconds, so an hour is `3600000`, in writes and filters alike. A fraction is refused, as Quickbase would drop it | | Checkbox | `true` or `false` | | Date | `YYYY-MM-DD`, so `today` is refused | | Timestamp | ISO 8601 with a time zone, like `2026-12-01T09:30:00Z` | | Time of day | `HH:MM` or `HH:MM:SS` | | Multi-select text | A list of text | | File | `{"file_name": ..., "text": ...}`, which uploads the text as a new version | `null` clears a field, except a file field, as Quickbase ignores an empty file value. `max_records_per_write`, 100 by default, limits how many records one `insert` or `update` may send. #### Limiting it to a scope Pass `scope` to `table.operation()` for a condition your code fixes that stays the same, such as one person's tool, or to `run()` for one that changes with each call, such as the user's project. Given to both, both apply. Only a scope given to `operation()` can be described to the model: the `where` description says that every record the tool reaches matches it, such as `Billable is true`, a write's schema leaves its fields out and says what new records get, and a write that sets one is refused with a message saying the scope fixes it. Without that, a model can't tell why it may not set a field, and may tell the person something untrue about it. `operation()` also checks an `insert` scope when it's made, rather than on the first insert: it must be only `==` conditions, whose fields the client's write rules let it set. Either way, the scope is added to every filter, so `{"any": [A, B]}` from the model becomes `scope AND (A OR B)`. An operation that names records, such as `update`, `run_formula`, or `download_file`, first counts how many of them match the scope, with one query, and refuses the whole call if any don't. A record can still change between that check and the call. A write can't set a field the scope uses, so `update` can't move a record out of it, and `run()` raises `QueryError` for a row that tries. `insert` fills those fields in from the scope, so with `scope=F("Project") == project`, every new record gets that project. That needs a scope of only `==` conditions joined with `&`, and `insert` refuses any other scope, as a new record couldn't be made to match it. With a scope passed only to `run()`, pass `fields=` without the scope's fields, so the model isn't offered them at all. A report runs with its own filter, so it can't take a scope, and `operation()` and `run()` refuse one rather than ignore it. Under a policy, fields in a scope must be ones the client may filter on. #### Reports, formulas, and files - **Reports** are offered by ID, with their names in the schema's description, as names can repeat. Pass `reports=` to choose them, as a list of IDs, and `report_descriptions={ID: text}` to say what each is for. A report's columns, filter, and sort are its own, so check what it shows before offering it. - **A formula's text is fixed by your code,** with `formula=`, and the model only chooses the record. A formula can read any table and field, and return the token owner's email, so a model never writes one. A client with a policy refuses formulas, unless it's `Policy.read_only()`. - **A downloaded file** comes back as a `FileDownload`, and `tool_result()` gives the model its text if it's UTF-8 text. Its contents are untrusted, like any record data. #### Describing fields `field_descriptions` takes `{label or ID: text}`, and the schema shows each one where the model reads it: on each field of a record, and as a list after the description of `where`, `select`, and `order_by`. Each must be for a field the operation offers, so a field that's renamed or hidden raises `UnknownFieldError` instead of losing its description silently. `where_schema()` takes the same argument. For a tool that lists what's there, `tool_result()` takes table, field, and report metadata, without `raw`: ```python async def list_fields(table_id: str) -> dict: return tool_result(await app.table(table_id).fields()) ``` #### When something is refused | What happened | Exception | What's left behind | |---|---|---| | The client can't run the operation at all, such as `insert` when read-only | `PolicyRefusedError`, from `operation()` | Nothing was sent | | The table has nothing for it, such as no file fields, or `formula` is missing | `ConfigurationError`, from `operation()` | Nothing was sent | | `fields` or `field_descriptions` names a field the client can't see, or that the operation doesn't offer | `UnknownFieldError`, from `operation()` | Nothing was sent | | An argument the schema doesn't allow, such as an unknown key or field, or a `limit` too high | `QueryError`, from `run()` | Nothing was sent | | A value that can't be written, such as `today` for a date | `InvalidValueError`, from `run()` | Nothing was sent | | A record named isn't in the scope, or doesn't exist | `QueryError`, from `run()` | Only the query that checks the scope | | A write sets a field the scope uses, or `insert` has a scope it can't fill in | `QueryError`, from `run()` | Nothing was sent | | A delete matches more than `max_records_per_call` | `QueryError`, from `run()` | Only the query that counts it | | Anything the table method raises | The same exception | As that method says | Return each one with `tool_error()`. Its message says where the mistake is, and doesn't repeat the values sent. #### What operations don't do - **They don't choose your tools.** Give the model a `preview_delete` tool, and run a `delete` only after a person approves it, as in [Checking a change before making it](https://matthew-williams-solutions.github.io/pyquickbase/agents/changes/#checking-a-change-before-making-it). - **They protect a request's shape, not its meaning.** A filter the schema allows can still match every record in the scope. - **A schema is a snapshot.** It's built from the client's metadata cache when you make the operation. `run()` checks against that same snapshot, and the client's limits are checked again on every request, so a change in Quickbase can make a call fail, but can't let more through. Make the operation again to pick up new fields. - **Labels and report names are untrusted.** They come from Quickbase, where anyone who can edit them can write any text, and the schema shows them to the model. ## Results and errors Source: ### Reading results Records, report text, field labels, table names, file names, and file contents are data, not instructions. Anyone who can edit them can write what they like, so they could contain prompt injection. Keep them apart from your agent's instructions, and don't let them trigger a write or delete unchecked. - Quickbase's formula error messages [can contain HTML](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/#errors-from-quickbase), such as a `javascript:` link, and errors pass it on unchanged. - File field values [include the uploader's email address](https://matthew-williams-solutions.github.io/pyquickbase/guides/files/#listing-versions), and user fields hold an email too. Decide whether a model or a log should see them. A policy leaves both out unless a rule that reads the table passes `user_emails=True`. - [Don't use a file's name as a path](https://matthew-williams-solutions.github.io/pyquickbase/guides/files/#downloading). Quickbase stores names as sent, including `../../etc/passwd`. ### Returning results to a model A tool's result goes back to the model as JSON. `tool_result()` and `tool_error()` turn the SDK's results and errors into plain data in the same shapes every time, so a model can tell whether it has every record, what went wrong, and whether anything changed. They work with any tool-calling framework, as `json.dumps()` always accepts what they return, and they don't need the network. ```python from pyquickbase import F, Policy, Quickbase, QuickbaseError, tool_error, tool_result def open_tasks(project: str, skip: int = 0) -> dict: """A tool for the agent, which chooses the project and which page to read.""" try: with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") where = (F("Project") == project) & (F("Status") == "Open") page = tasks.query_page(where=where, select=["Title", "Due Date"], limit=50, skip=skip) return tool_result(page, compact=True) except QuickbaseError as error: return tool_error(error) ``` #### What a result holds Every result has `"ok": true`, and a key for what it holds: | You pass | It holds | |---|---| | A `Page`, from `query_page()` | `records`, or `columns` and `rows` with `compact=True`, and `page` | | A list of records or dictionaries, such as from `query()` or `summary_rows()` | The same, with `page.total` as null, as it isn't known | | A `Record` or None, from `get()` or `first()` | `record`, which is null for None | | A `WriteResult` | `created_ids`, `updated_ids`, and `unchanged_ids` | | A list of `SummaryRow`, from `summarize()` | `groups`, each with `group`, `count`, and the totals it has | | An `UpdatePreview` | `changes`, with `before` and `after` for each field, `unchanged_ids`, and `missing_ids` | | A `FormulaResult` | `value` and `type` | | A `count` operation's result | `count` | | A `delete` operation's result | `deleted_count` | | Text, a number, a date, or a bool, such as from `count()` | `value` | Records are keyed by field label. Values are written the same way whatever their field: - A date is `2027-01-15`, and a timestamp is in UTC, as `2027-01-15T14:30:00Z`. - Every kind of empty value is null. Quickbase returns `""` for an empty text field, null for an empty number, and `{"url": "", "versions": []}` for an empty file, so a model would otherwise have to know each. - A `Decimal` is a number when that's exact, and text when a number would lose digits. - A user is `{"id", "name", "email"}`, with a null email when a client's [read rules](https://matthew-williams-solutions.github.io/pyquickbase/agents/#limiting-access) leave it out. A file is `{"url", "versions": [{"number", "file_name", "uploaded", "creator"}]}`, or with `compact`, the latest file's name. #### Paging and long results `page` says how much of the result the model has: ```json {"returned": 50, "total": 1234, "skip": 0, "next_skip": 50, "has_more": true} ``` Give the tool a `skip` argument, as above, so the model can ask for the next page with `next_skip`. Use `query_page()` rather than `query()`, as only a page knows its `total`. Results cost tokens, so return as little as the model needs: - **Pass `compact=True`** when a tool returns many records. Each label is sent once, as `columns`, and each record is a list of values in `rows`, rather than repeating every label on every record. A file value becomes the latest file's name. For 50 records of three short fields, it's about half the size. The saving is larger when labels are long and values short. - **Pass `max_chars`** so that one call can't fill the model's context, however wide the table or long its text. Records and summary groups are included from the first until the next wouldn't fit, then `page.has_more` is true and `page.next_skip` says where to carry on, so paging still reaches every one. At least one is always included, with its text cut shorter if it's too large on its own. 32,000, about 8,000 tokens, suits most tools. - **Pass `max_records`** to cut a list your code already has. `page.has_more` is then true, so the model knows it doesn't have them all. - **Pass `max_text_chars`** to shorten long text, such as notes. A shortened value ends by saying how many characters were left out, and the result has `"text_truncated": true`, so a model can't mistake it for the whole value. - **Select only the fields the tool needs.** Every field is in every record. #### Errors Every error has `"ok": false`, and an `error` with these keys: | Key | What it holds | |---|---| | `code` | What went wrong, as a fixed word. Codes don't change within 1.x, unlike the wording of `message` | | `message` | The SDK's explanation, which says where the mistake is. It doesn't repeat the values a model sent | | `hint` | What the model should do next, in fixed words for each code. A failed schema change has its own, as it has no failed rows, and so does a `partial_write` whose `data_changed` is `unknown`, as the failed batch may have been written | | `retryable` | Whether the same call can succeed later, without changing it | | `data_changed` | `no` if nothing was changed, `partly` if part of a write was, and `unknown` if a write may have been | | `retry_after` | For a rate limit, the seconds Quickbase asked to wait, when it said | | `status` | For an error from Quickbase, the HTTP status | | `written`, `failed_rows` | For a write that failed, the IDs that were created, updated, and left unchanged, and each failed row's `index`, `reason`, `field_id`, and `messages` | ```json { "ok": false, "error": { "code": "partial_write", "message": "1 of 3 rows failed ...", "hint": "Some records were changed. Fix the failed rows and send only those again.", "retryable": false, "data_changed": "partly", "written": {"created_ids": [4, 5], "updated_ids": [], "unchanged_ids": []}, "failed_rows": [{"index": 2, "reason": "duplicate", "field_id": 10, "messages": ["..."]}] } } ``` `data_changed` is what tells a partial write from a complete failure. A model should check the data before repeating a call whose `data_changed` isn't `no`, as repeating an insert can add duplicates. A model can decide what to do next from `retryable`, `data_changed`, and `code` alone, without reading the message. Check these in order, and act on the first that applies: | If | Then | |---|---| | `retryable` is true | Wait `retry_after` if it's given, then send it again. If `data_changed` is `unknown`, check the data first | | `code` is `write_permission_denied` | Stop, and tell the user what was written | | `code` is `internal` | Stop, and tell the user | | `data_changed` is `unknown` | Check what was written, then send only what's missing | | `data_changed` is `partly`, or `code` is `write_failed` | Fix `failed_rows`, and send only those. For a schema change, preview it again | | `code` is `needs_confirmation` | Preview again, show the person, then confirm | | `code` is `too_many_records` | Count without totals, which works at any size, or ask the user to narrow the question | | `code` is `invalid_arguments`, `invalid_value`, `unknown_table`, `unknown_field`, `unknown_report`, `unsupported_report`, `not_found`, `bad_request`, or `read_refused` | Fix the call, and try again | | Anything else | Stop, and tell the user | The order matters: a `write_permission_denied` can be `partly`, and an `internal` error is always `unknown`, but neither is worth sending again. | Code | Raised for | `data_changed` | `retryable` | |---|---|---|---| | `needs_confirmation` | A change without the confirmation from its preview or plan, or with one that's expired or for something else | `no` | No | | `invalid_arguments`, `invalid_value` | A filter, argument, or value that can't be sent | `no` | No | | `too_many_records` | A summary that would read more records than its `max_records_scanned`, with `matched` and `limit` | `no` | No | | `unknown_table`, `unknown_field`, `unknown_report` | A name that doesn't exist, or that the token or the read rules hide | `no` | No | | `unsupported_report` | A report that can't be read that way | `no` | No | | `read_refused`, `write_refused` | The client's limits refused it, and it wasn't sent | `no` | No | | `write_failed` | Every row of a write failed | `no` | No | | `partial_write` | Some rows were written and some weren't, or a later batch failed | `partly`, or `unknown` if the failed batch may have been written | No | | `write_permission_denied` | The token's role refused a write | `no`, or `partly` if earlier rows were written | No | | `bad_request`, `not_found`, `permission_denied`, `authentication`, `api_error` | Quickbase answered with an error | `no` | No | | `rate_limited` | Quickbase is limiting requests, and the retries ran out | `no` | Yes | | `server_error`, `timeout`, `network_error` | Quickbase failed, or didn't answer | `unknown` | Yes | | `invalid_response` | Quickbase's answer couldn't be read | `unknown` | No | | `schema_mismatch`, `misconfigured` | The tool's setup no longer matches, which the model can't fix | `no` | No | | `quickbase_error` | Any other error from the SDK | `no` | No | | `internal` | An exception from outside the SDK, such as a bug in the tool | `unknown` | No | An exception from outside the SDK gets the message "An unexpected error occurred in the tool.", so a bug's details, and any values in them, don't reach the model. Log it yourself before returning it. #### What these refuse | What happened | Exception | What's left behind | |---|---|---| | `tool_result()` got something it can't describe, such as an `AppInfo`, a dictionary, or a set | `TypeError` | Nothing was returned | | A record holds a value of a type JSON can't hold, such as `bytes` | `TypeError` | Nothing was returned | | `max_records`, `max_text_chars`, or `max_chars` isn't a positive whole number | `ValueError` | Nothing was returned | #### What these don't do - **They don't make record data safe.** Labels and values are still [untrusted](https://matthew-williams-solutions.github.io/pyquickbase/agents/results/#reading-results), so a record can hold text meant to steer the model. Keep results apart from your instructions. - **They don't hide fields.** They return what you give them, so select only what the model should see, or use [read rules](https://matthew-williams-solutions.github.io/pyquickbase/agents/#limiting-access), which the SDK enforces. - **Quickbase's own wording passes through.** An error from Quickbase keeps its message, up to 500 characters, because a model needs it to fix a request Quickbase refused, such as `Criteria is invalid`. A row's `messages` are Quickbase's too. They can contain HTML, and may repeat what was sent. - **An empty list reads as no records,** even if it came from `summarize()`, as nothing in an empty list says what it held. ### Linking to Quickbase A tool can return links to what a model read or changed, so that a person can check it in the Quickbase interface. Each method looks up the IDs it needs from the client's cache, as any other method does, and puts only those IDs in the link, never a field's value. A table, field, or report that the client's policy hides is refused as unknown. ```python from pyquickbase import AsyncTable, F, tool_result async def list_open_tasks(tasks: AsyncTable) -> dict: page = await tasks.query_page(where=F("Status") == "Open", limit=50) # https:///nav/app//table//action/dr?rid={record_id} pattern = await tasks.record_url_template() return {**tool_result(page), "record_link": pattern} ``` Send the pattern once, rather than a link for each record, and say in the tool's description that `{record_id}` is replaced by a record's `Record ID#`. [`record_url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#links) builds one record's link, and `url()` a table's, app's, or report's. Others link to the pages a change to the schema sends a person to, such as `field_url()` and `fields_url()` for a table's fields, `relationships_url()`, and `Report.url(edit=True)` for a report's settings. To show which records a query asked for, `compile_where()` returns a condition as Quickbase's query text, such as `{7.EX.'Open'}`. ### Counts and totals Don't have a model count or add up records itself, because it can miss some or get the arithmetic wrong. Give it a tool that calls [`summarize()`](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/#summarizing-records), and always set `max_records_scanned`, so that a broad request can't read a whole large table. If the limit is exceeded, nothing is returned but the error, so a model can't report a total from only part of the data. ```python from pyquickbase import ( F, Policy, Quickbase, QuickbaseError, tool_error, tool_result, where_from_dict, ) def task_totals(project: str, where: dict | None = None) -> dict: """A tool for the agent, which can only choose the project and a filter on Status.""" try: condition = F("Project") == project if where: condition = condition & where_from_dict(where, fields=["Status"]) with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") rows = tasks.summarize( group_by="Status", where=condition, sum="Amount", max_records_scanned=5000 ) return tool_result(rows) except QuickbaseError as error: return tool_error(error) ``` `tool_result()` writes dates in `min` and `max` as text, as it does everywhere. A `max_records_scanned` that's exceeded is a `too_many_records`, with `matched` and `limit`. A summary that only counts reads no records, so it's never refused this way. ## Previews and schema changes Source: ### Checking a change before making it #### Requiring the preview Telling a model to preview first isn't enough: in testing, a model updated a record without a preview, or any approval. With `require_preview=True`, the `update` and `delete` operations only run with the `confirmation` that `preview_update` or `preview_delete` returned, for exactly the same arguments: ```python from pyquickbase import tool_result preview = tasks.operation("preview_update") update = tasks.operation("update", require_preview=True) arguments = {"records": [{"Record ID#": 1, "Status": "Done"}]} shown = tool_result(preview.run(arguments)) # holds "confirmation" # ... the person approves what the preview showed ... update.run({**arguments, "confirmation": shown["confirmation"]}) ``` - **The arguments must match.** The order of records, and of fields in a record, doesn't matter, but any other difference refuses the call, so a model can't preview one change and make another. - **A confirmation lasts 15 minutes.** - **A delete's confirmation holds how many records matched**, so the delete is refused if that has changed since the preview. A preview of more records than the delete may match gives no confirmation. - **It's signed with a key each client makes when it starts**, so nothing is stored, a model can't make one up, and the preview and the change must come from the same client. Give them the same `scope`. ```mermaid --- config: sequence: { width: 100, actorMargin: 20 } --- sequenceDiagram participant P as Person participant M as Model participant S as pyquickbase M->>S: preview_update S-->>M: The changes, and
a confirmation M->>P: The changes P-->>M: Approve Note over P,M: pyquickbase
can't see this M->>S: update, with the
confirmation alt Same call, within
15 minutes S-->>M: Written else Changed or expired S-->>M: needs_confirmation,
nothing written end ``` It makes sure a preview happened, not that a person saw it. Ask your client to show each change for approval too. #### Previews without operations Have a tool show what it would do, and make the change only once someone has approved it. A preview only reads, so the tool that shows it can use a read-only client, and only the tool that writes needs more. ```python from pyquickbase import F, Policy, Quickbase, tool_result, where_from_dict def preview_status_change(record_id: int, status: str) -> dict: """The tool the model calls first. It changes nothing.""" with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") preview = tasks.preview_update([{"Record ID#": record_id, "Status": status}]) return tool_result(preview) def preview_delete_tasks(project: str, where: dict) -> dict: """Shows how many tasks a delete would remove, and the first few.""" with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") condition = (F("Project") == project) & where_from_dict(where, fields=["Status"]) page = tasks.preview_delete(condition, select=["Title"], limit=10) return tool_result(page) ``` The delete preview's `page.total` is how many tasks the delete would remove. Add `tool_error()` as in [Returning results to a model](https://matthew-williams-solutions.github.io/pyquickbase/agents/results/#returning-results-to-a-model) to return a refusal too. Both previews' results also have `events`: the table's active webhooks, Quickbase actions, email notifications, and automations, which the change may set off, with a warning when there are any. Show these to the person too, as a change can send email or reach other systems. `events` is `null` when the token can't read them, which needs an app administrator. The values in a preview are record data, so treat them as [untrusted](https://matthew-williams-solutions.github.io/pyquickbase/agents/results/#reading-results), and only return fields the model should see. A preview isn't a guarantee, as records can change before the write, and [it doesn't check](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/#previewing-a-write) whether Quickbase will accept it. Compare a delete's result with the preview's `total`, and tell the user if they differ. ### Changing the schema from an agent `app.operation()` offers three schema tools. `describe_schema` lists the tables and fields the client can read. `preview_schema_changes` says what a list of changes would do. `apply_schema_changes` makes them, but only with the `confirmation` that `preview_schema_changes` returned for exactly those changes, within 15 minutes, while the schema is as it was. Unlike `require_preview` for records, this can't be turned off. ```python from pyquickbase import AsyncQuickbase, Policy, allow, tables, tool_result policy = Policy( rules=[ allow.read("Tasks"), allow.schema( "Tasks", "add_field", "change_field", "formula", field_types=["text", "numeric", "date", "checkbox", "text-multiple-choice"], ), allow.create_tables(), allow.read(tables.created()), allow.schema(tables.created(), "add_field", "change_field", "delete_field"), ] ) async with AsyncQuickbase.from_env() as setup: bound = await policy.bind(setup.app("bq1234567")) async with AsyncQuickbase.from_env(token_env="QB_SCHEMA_TOKEN", policy=bound) as qb: app = qb.app("bq1234567") describe = await app.operation("describe_schema") plan = await app.operation("preview_schema_changes", max_changes=10) apply = await app.operation("apply_schema_changes", max_changes=10) # What a model sends, such as adding a text field to Tasks. arguments = { "changes": [{"action": "create_field", "table": "Tasks", "label": "Crew", "type": "text"}] } shown = tool_result(await plan.run(arguments)) # holds "confirmation" if nothing blocks it ``` A change names what it does in `action`, such as `create_field`, `create_lookup_field`, or `create_summary_field`, and a relationship's tables in `child_table` and `parent_table`. A summary's `function` is `sum`, `avg`, `min`, `max`, `count`, `std_dev`, `distinct_count`, `combined_text`, or `combined_user`. `run()` also reads what models tend to send instead: `kind` for `action`, `create_lookup` and `create_summary`, `child` and `parent`, and Quickbase's own function names, such as `SUM` or `STD-DEV`, or words like `total` and `average`. The result lists each change with its `status`: `ready`, `already_done`, or `blocked`. - **Each schema offers only what the policy allows:** the actions, the tables that can change, and the field types they can create. A plan reports anything the policy would refuse as a blocker, so the person sees it before approving. - **Each change is granted where it lands.** A lookup is granted on the child, and needs `read` on the parent's field. A summary is granted on the parent, and needs `read` on the child's field and `filter` on the fields its filter uses. A relationship needs both tables, as it adds fields to the parent too. - **Formulas, lookups, and summaries may only use fields the client can already read.** A formula can't call a function that reads other records, such as `GetRecords()`. It can't make a link or rich text, which act as whoever clicks them. A field's formula is only shown when everything it uses is readable, and a summary's filter when every field it uses is readable on the child. - **A plan says what the policy would do with each change,** in its `policy_effects`: who could read and write a new field, the classes a new formula, lookup, or summary would get, and which rules lose a deleted field or table. A deletion that would leave a rule matching nothing is a blocker, and so is a label a field the client can't see already uses. - **Tables the client creates are covered by `tables.created()`.** Creating one gives the client a new policy that includes it, so a table the model creates is off limits unless a rule names `tables.created()` or `tables.all()`. - **Use a separate token for schema changes.** Changing the schema needs more rights than reading and writing records, so keep those on a token with less. - **A confirmation is signed with the client's key, and tied to its policy.** Give clients in several processes the same `confirmation_key`, and the same bound policy, if a plan may be applied by another one. A plan's `tool_result()` has `plan.changes`, each with its `summary`, `risk`, `settings`, `warnings`, `blockers`, any `policy_effects`, and a `link` to check it in Quickbase, then `blockers`, `warnings`, `confirmation`, and `expires_at`. Show the person the summaries and warnings, and send `apply_schema_changes` the same changes with the confirmation once they approve. It makes sure a plan happened, not that a person saw it, so ask your client to show each change for approval too. ### Writes Quickbase writes the rows it can and reports the rest, so a failed write can still have changed data. Check `WriteError`, which [lists both](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/#when-rows-fail): ```python from pyquickbase import WriteError try: result = tasks.insert(rows) except WriteError as error: print("Written:", error.result.created_ids) for failed in error.result.errors: print("Failed:", rows[failed.index], failed.messages) ``` Rate limits, connection failures, and reads that timed out are retried for you. A write that timed out isn't, because it may already have applied, so an agent should check the data before trying again. | Exception | What it means for a write | |---|---| | [Mistakes caught before sending](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/#mistakes-caught-before-sending), such as `QueryError`, `InvalidValueError`, and `PolicyRefusedError` | This request wasn't sent | | `WriteError` | Some rows may have been written. If a large write stopped at a later batch, `result` covers only the earlier batches, `errors` can be empty, and the failed batch may have been written | | `NetworkError`, `RequestTimeoutError`, `ServerError`, `InvalidResponseError` | Unknown, so check the data first | | Other `APIError`s, such as `BadRequestError` | Quickbase answered with an error, so fix the cause rather than repeating the request | `create_field()` with `required` or `unique` takes two requests, so if it raises an error, the field [may already exist](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). This includes `PolicyRefusedError`, when only the first is allowed. Repeating an `update()`, or an `upsert()` on a unique field, reports the record as unchanged rather than adding another, but repeating an `insert()` can add a duplicate. ## Policies Source: A client takes one `policy`, which says what it may read and change. | Policy | What the client can do | Use it for | |---|---|---| | None | Whatever the token can | Your own scripts | | `Policy.read_only()` | Read whatever the token can, and change nothing | Reports, dashboards, and anything that only looks | | `Policy(rules=[...])`, bound to an app | Only what its rules allow | An AI agent, or a job with a narrow purpose | A policy protects against bugs in your code and mistakes by a model. See [what it doesn't do](https://matthew-williams-solutions.github.io/pyquickbase/policies/limits/), and give your token access to as little as possible, so that Quickbase enforces the limit too. ### How a policy decides A policy works on both sides of each request, including one sent through `qb.request()`: ```mermaid flowchart TD R["Your request"] --> C["Checked before sending
against every rule"] C -- refused --> X["PolicyRefusedError
nothing sent"] C -- allowed --> Q["Quickbase
applies the token's role"] Q --> T["Trimmed on return
hidden tables and fields
left out"] ``` A table method may read the table's metadata before its own request is checked. A hidden table or field looks as if it doesn't exist. When a request is refused, `error.decision.layer` says which check refused it: | `layer` | Refused because | |---|---| | `policy` | No rule allows it, or a deny rule covers it. A deny always wins | | `rows` | A row limit can't be added to it, or it would show a row-limited table's records elsewhere | | `derived` | A formula, lookup, or summary would read something the client can't | A confirmation that `require_preview` asks for isn't part of the policy. A missing or mismatched one raises `ConfirmationError`. ### Binding a policy 1. **Write a `Policy`** of rules, by table name and field label. 2. **Bind it to the app** once. Binding looks up every name, reports every problem at once, and pins each table and field by ID. 3. **Give the bound policy to a client.** From then on, every request is checked against it. ```mermaid flowchart TD P["Policy
tables and fields
by name"] -- "bind(app)" --> B["BoundPolicy
tables and fields
by ID"] B -- "policy=" --> C["Client"] B -- "to_json(), then
from_json()" --> O["Another process"] ``` A bound policy holds tables and fields by ID, so renaming one doesn't change what a rule means. A field added after binding follows `new_fields` until you bind again, and a table the client creates gives it a new policy, `qb.policy`, that includes it. ### Making a client read-only `Policy.read_only()` guarantees a client can't change data. It only sends GET requests, record queries, report runs, and formula runs, and reads them as a client without a policy would. Running a report adds to its usage count in Quickbase, but changes no records. It names no table, so a client takes it without binding. ```python from pyquickbase import Policy, Quickbase with Quickbase(realm="acme", token="your_user_token", policy=Policy.read_only()) as qb: print(qb.app("bq1234567").tables()) ``` ## Your first policy Source: This builds a policy for an agent that helps a crew keep their tasks up to date, one step at a time. The app has a `Tasks` table with `Title`, `Status`, `Notes`, `Due Date`, `Project`, `Amount`, which holds what a task costs, and `Code`, an internal reference. Each step asks the bound policy what it allows, with `allows()` and `explain()`, which send nothing. ### 1. Start read-only `Policy.read_only()` reads whatever the token can and changes nothing. It names no table, so it needs no binding. Start here, and loosen it only where the job needs to. ```python from pyquickbase import Policy, Quickbase with Quickbase.from_env(policy=Policy.read_only()) as qb: tasks = qb.app("bq1234567").table("Tasks") for task in tasks.query(select=["Title"], max_total=3): print(task["Title"]) ``` ### 2. Allow what the job needs The agent reads tasks and changes their status and notes. Anything no rule allows is refused. ```python from pyquickbase import Policy, Quickbase, allow policy = Policy( rules=[ allow.read("Tasks", max_paging_depth=50), allow.update("Tasks", ["Status", "Notes"]), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) print(bound.allows("update", "Tasks", field="Status").allowed) # True print(bound.allows("update", "Tasks", field="Due Date").allowed) # False ``` `max_paging_depth=50` means one query can reach at most 50 records, so a mistake can't page through the whole table. ### 3. Hide what it shouldn't see Costs and internal codes aren't the agent's business. Put them in a class and deny the class. A deny always wins over an allow, and a formula, lookup, or summary that reads a denied field is denied too. ```python from pyquickbase import Policy, Quickbase, allow, deny, fields policy = Policy( classes={"internal": ["Tasks.Amount", "Tasks.Code"]}, rules=[ allow.read("Tasks", max_paging_depth=50), allow.update("Tasks", ["Status", "Notes"]), deny.all(fields.classed("internal")), ], ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) print(bound.allows("read", "Tasks", field="Amount").allowed) # False print(bound.explain("read", "Tasks", field="Amount")) ``` To a client with this policy, `Amount` and `Code` look as if they don't exist. ### 4. Keep it to some records `limit.rows()` ANDs a condition onto every query, summary, and delete, so the agent only reaches one project's tasks. ```python from pyquickbase import F, Policy, Quickbase, allow, deny, fields, limit policy = Policy( classes={"internal": ["Tasks.Amount", "Tasks.Code"]}, rules=[ allow.read("Tasks", max_paging_depth=50), allow.update("Tasks", ["Status", "Notes"]), deny.all(fields.classed("internal")), limit.rows("Tasks", F("Project") == "Roof"), ], ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) print(bound.row_limit(setup.app("bq1234567").table("Tasks").info().id)) ``` The agent now sees the `Tasks` table like this: ```mermaid flowchart TD subgraph T["Tasks where Project is Roof"] U["Read and update
Status, Notes"] R["Read only
Title, Due Date, Project"] H["Hidden
Amount, Code"] end classDef hidden fill:#e5393526,stroke:#e53935 class H hidden ``` ### 5. Use it Give the bound policy to the agent's client. A refused request raises `PolicyRefusedError` before anything is sent, and its message says what would allow it, so you can return it to the model as the tool's result. ```python from pyquickbase import Policy, PolicyRefusedError, Quickbase, allow policy = Policy(rules=[allow.read("Tasks"), allow.update("Tasks", ["Status", "Notes"])]) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Tasks") tasks.update([{"Record ID#": 1, "Status": "Done"}]) # Allowed try: tasks.update([{"Record ID#": 1, "Due Date": None}]) except PolicyRefusedError as error: print(error.decision.hint) ``` Next, see [Writing rules](https://matthew-williams-solutions.github.io/pyquickbase/policies/rules/) for every rule, and [Recipes](https://matthew-williams-solutions.github.io/pyquickbase/policies/recipes/) for complete policies. ## Writing rules Source: A policy is a list of rules over tables and fields, by name and label. Bind it to the app once, which looks up every name and reports every problem at once, then give the bound policy to the client. ```python from pyquickbase import F, Policy, Quickbase, allow, deny, fields, limit, tables policy = Policy( classes={ "pay": ["Tasks.Amount"], "internal": [("Tasks", "Code"), fields.labeled_like("Old*")], }, rules=[ allow.read(tables.all(), max_paging_depth=50), allow.insert("Tasks", fields.all()), allow.update("Tasks", ["Status", "Due Date", "Notes"]), allow.delete("Tasks", max_records_per_delete=3), limit.rows("Tasks", F("Project") == "Roof"), deny.all(fields.classed("pay", "internal")), ], ) with Quickbase(realm="acme", token="your_user_token") as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase(realm="acme", token="your_user_token", policy=bound) as qb: tasks = qb.app("bq1234567").table("Tasks") tasks.update([{"Record ID#": 1, "Status": "Done"}]) # Allowed # tasks.update([{"Record ID#": 1, "Amount": 90}]) # Raises PolicyRefusedError ``` With the async client, it's `await policy.bind(app)`. Bind with a client that has no policy, so it can see every table and field. **Anything no rule allows is refused, and a deny always wins.** Each rule names its tables first: a table's name or ID, a list of them, `tables.all()` for every table in the app and any the client creates, or `tables.created()` for only those the client creates. Most then name fields: labels or IDs, `fields.all()`, `fields.new()` for fields added after binding, `fields.classed(...)`, or `fields.labeled_like(...)`, which matches labels such as `_*`, ignoring case. | Rule | What it allows | |---|---| | `allow.read(tables, fields, ...)` | Reading the fields, and filtering and sorting by them, unless it gives its own `filter=` or `sort=`. It also takes `max_paging_depth`, `max_records_scanned`, and `user_emails` | | `allow.filter(...)`, `allow.sort(...)` | Filtering or sorting by fields without reading them | | `allow.insert(...)`, `allow.update(...)` | Setting the fields in new or existing records. An upsert needs both | | `allow.delete(tables, max_records_per_delete=None)` | Deleting records. With `max_records_per_delete`, the client counts what matches first | | `allow.download(...)`, `allow.delete_file(...)` | Downloading files, or deleting their versions | | `allow.report(table, reports)` | Running these reports, by name or ID, or every report with `None` | | `allow.schema(tables, *verbs, ...)` | Changing the schema, as [below](https://matthew-williams-solutions.github.io/pyquickbase/policies/schema/) | | `allow.create_tables()` | Creating tables in the bound app | | `deny.all(selector)`, `deny.read(...)`, `deny.update(...)`, and the rest | Refusing what they name, whatever allows it | | `limit.rows(tables, where)` | Reaching only the records that match, as [below](https://matthew-williams-solutions.github.io/pyquickbase/policies/rows/) | Things to know: - **Writing a field doesn't need read access to it,** so a client can set a field it can't see. Such a field can still be named, but its values aren't returned. - **`Record ID#` is readable wherever anything is,** because the SDK uses it to page through records. - **`max_paging_depth` limits each query, not the client.** A query needs a `limit` of at most this, and it's refused rather than cut short. Where several rules read a table, the highest applies. It defaults to 100, and `max_records_scanned` to 10,000. A summary that only counts reads no records, so `max_records_scanned` doesn't limit it. - **A field you may filter or sort by reveals something about its values,** even if it isn't returned, because a model can see which records match and in which order. - **A report's own filter and sort can use any field,** and someone can change them in the Quickbase interface, so list only reports you've checked. A summary report's columns can't be matched to fields, so allowing one shows all of them. - **Some things are refused unless the policy is `Policy.read_only()`:** running a formula, which can read any table, an app's details and roles, and any request it doesn't recognize. Use a separate client for those. - **A delete needs `filter` on the fields its filter uses,** because the number deleted shows how many records matched. - **`insert` alone can't be limited to creating records on a table with a custom key field.** If someone has changed a table's key field in the Quickbase UI, a row with an existing value in that field [changes that record](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#writes). `table.insert()` refuses those tables, so use `upsert()`, which needs both `insert` and `update`. `qb.request()` can't look up a table's key field, so any write through it without a merge field needs both. ## Classes and new fields Source: ### Classes, and what derived fields read A class names a group of fields, such as everything about pay, for `fields.classed()`. Each entry is `"Table.Field"`, `("Table", "Field")` when a name holds a dot, `fields.all()`, or `fields.labeled_like(...)`, which matches in every table. **A formula, lookup, or summary is in every class of the fields it reads,** through any chain of them, including the fields a summary's filter uses. So `deny.all(fields.classed("pay"))` hides a formula of `Hourly Rate`, a lookup of that formula on another table, and a summary of the lookup. A formula that can't be read counts as reading every field of its table, and one that calls a function that reads other records counts as reading every field of the app. ```mermaid flowchart TD subgraph Staff direction LR H["Hourly Rate
class: pay"] --> D["Day Rate
formula"] end subgraph Shifts L["Staff - Day Rate
lookup"] end subgraph Projects T["Total Cost
summary"] end D --> L --> T classDef pay fill:#e5393526,stroke:#e53935 class H,D,L,T pay ``` Every field in the chain is in `pay`, so `deny.all(fields.classed("pay"))` hides all four, on all three tables. **A new formula, lookup, or summary may only read what the client may read,** as anyone who can read it sees what it shows. The SDK's methods check this before sending one, and `qb.request()` can't make one. ### Fields added after binding The bound policy holds every table and field by ID, as Quickbase never reuses a field's ID within a table, so a rule can't come to mean another field. A renamed field keeps its rules until you bind again. A field added after binding gets `new_fields`: | `new_fields` | A new field is | |---|---| | `"read"`, the default | Read, filtered, and sorted wherever a rule reads `fields.all()` or `fields.new()` | | `"hidden"` | Hidden | | A class's name | In that class, so the rules for the class apply | **A new field is never writable** until the policy is bound again, whatever the rules say. ## Limiting rows Source: `limit.rows(tables, where)` keeps a client to the records of those tables that match a condition: | Request | What `limit.rows()` does | |---|---| | Queries, summaries, and deletes, including through `qb.request()` | ANDs the condition onto them | | A filter on a field the condition uses | Allows it, unless a deny rule covers the field | | A new summary or count field, from an `ensure_` method or a plan | Gets the limit in its filter | | An existing summary whose filter doesn't hold the limit | Hidden | | A lookup from a row-limited table | Hidden, and a new one is refused | | A lookup onto a row-limited table | Allowed | | Reports, file downloads, deleting file versions, and formulas | Refused | | Inserts, upserts, and updates by `Record ID#` | Not checked | It's defense in depth. Quickbase's [record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules) for the token's role are the real boundary, so set those as well. ## Schema changes Source: `allow.schema(tables, *verbs)` grants schema changes on the table where each change lands: | Verb | What it allows | Granted on | |---|---|---| | `add_field` | Adding fields, of `field_types=[...]` if given | The table | | `change_field`, `delete_field` | Changing or deleting the fields in `fields=`, or every field | The table | | `formula` | Making or changing a formula, with `add_field` or `change_field` | The table | | `lookup` | Adding a lookup, which also needs `read` on the parent's field | The child | | `summary` | Adding a summary, which also needs `read` on the child's field, and `filter` on every field its filter uses | The parent | | `link`, `unlink` | Making or deleting a relationship | **Both tables**, as Quickbase adds a link field and an "Add" record formula to the parent. `unlink` also needs `delete_field` wherever its lookups and summaries are | | `change_table`, `delete_table` | Renaming the table, or deleting it and its records | The table | For a relationship, the grant goes where the new field appears: ```mermaid flowchart TD P["Projects, the parent
Grant summary here, and
read on the child's field"] C["Tasks, the child
Grant lookup here, and
read on the parent's field"] P -- "link and unlink
are granted on both" --- C ``` A formula can't make a link or rich text (`url` or `rich-text`), as that acts as whoever clicks it. **Creating a table gives the client a new policy,** with the table in its `created` list, so that `tables.created()` covers it. `qb.policy` is always the current one, and nothing is changed in place. Save it with `qb.policy.to_json()` to use in another process, and load it with `BoundPolicy.from_json()`. ## Rules of your own Source: For logic the rules can't express, add an object with a `decide(request)` method to `rules`. It sees each request the built-in rules allow, as a `ReadRequest` or `WriteRequest`, which holds no values, so it's safe to log. It returns None to leave the request alone, or a `Decision` with `allowed=False` to refuse it. **It can only refuse,** so a bug in it can't allow more than the rules do. ```python from pyquickbase import Decision, Policy, WriteRequest, allow, fields class SmallBatches: def decide(self, request): if isinstance(request, WriteRequest) and (request.row_count or 0) > 100: return Decision(allowed=False, verb="insert", reason="Up to 100 rows at a time") return None policy = Policy(rules=[allow.insert("Job Tasks", fields.all()), SmallBatches()]) ``` `offers()` asks about one field and verb at a time, so a rule that decides each field on its own is offered exactly what it allows. It runs once per request, so a write split into batches is checked batch by batch, and it sees each batch's own `row_count`. A custom rule can't be saved with `to_json()`, so pass it to `from_json(rules=[...])` again. A `WriteRequest` says what a write would do: | Attribute | What it holds | |---|---| | `action` | `insert`, `update`, `upsert`, `delete`, `delete_file`, a schema change such as `create_field` or `delete_relationship`, or `other` | | `table_id` | The table's ID, or for a relationship, the child's | | `field_ids` | The IDs of the fields it sets, or for `update_field` and `delete_field`, the fields it changes | | `row_count` | How many rows are in this request | | `app_id` | For `create_table` and `update_table`, the app | | `parent_table_id` | For a relationship, the parent table | | `field_type` | For `create_field`, the new field's type | | `derived` | The formula, lookup, and summary fields it makes or changes | | `method`, `path` | The HTTP request, for anything `action` doesn't cover | A `ReadRequest` says what a read would do: | Attribute | What it holds | |---|---| | `action` | `query`, `summarize`, `delete`, `report`, `summary_report`, `file`, `formula`, `tables`, `fields`, `relationships`, `reports`, `app`, `roles`, or `other` | | `table_id` | The table's ID | | `field_ids`, `filter_field_ids`, `sort_field_ids` | The fields it returns, filters on, and sorts by | | `limit`, `skip` | How many records it asks for, which Quickbase calls `top`, and how many it passes over | | `report_id` | The report it runs | | `method`, `path` | The HTTP request, for anything `action` doesn't cover | **Treat `None` as "couldn't be determined", not as "nothing".** For example, `field_ids` is `None` if a row writes a field ID in an unusual form, such as `07`, which Quickbase reads as field 7, and a query without `sortBy` has `None` for `sort_field_ids`, because the table's default sort would apply. Have your rule ignore an `action` it doesn't recognize, as a later version can add one. `upsert` means a write that may do either, which isn't only `table.upsert()`. Any merge on a field other than `Record ID#` is one, because Quickbase creates a record when the value doesn't match. So is a write without a merge field that goes through `qb.request()` or to a table with a different key field, because it can change an existing record. ## Debugging refusals Source: ### When something is refused A refusal's message says what was refused, by name, which rule refused it, and what would allow it: ``` Refused by policy: updating Labor Cost (6) in Job Tasks (bqtasks). No rule allows it. (rule: none) Nothing was sent. To allow it: add "Labor Cost" to an allow.update("Job Tasks", ...) rule. ``` | What happened | Exception | What's left behind | |---|---|---| | A request the policy doesn't allow | `PolicyRefusedError`, whose `decision` says why | Nothing was sent | | A table or field the policy hides is named | `UnknownTableError` or `UnknownFieldError` | Nothing was sent | | A later batch of a write was refused | `WriteError` | **Earlier batches were written**, and are in `error.result` | | A policy that names a table, field, or class that doesn't exist | `ConfigurationError`, listing every one | It wasn't bound | | A `Policy` that names tables passed to a client without binding | `ConfigurationError` | The client wasn't created | | A realm, token, or `base_url` that can't be used, such as `http://` to another host | `ConfigurationError` | The client wasn't created | | `from_env()` with a variable missing or empty | `ConfigurationError` | The client wasn't created | ### Asking the policy A bound policy answers questions without sending anything: ```python from pyquickbase import Policy, Quickbase, allow policy = Policy(rules=[allow.read("Tasks"), allow.update("Tasks", ["Status", "Notes"])]) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) bound.allows("update", "Tasks", field="Amount").allowed # False print(bound.explain("lookup", "Tasks", source=("Tasks", "Amount"))) bound.offers("Tasks").updatable # What an operation would offer ``` Each returns or explains a `Decision`, with `allowed`, the `layer` that decided, the `rule` as written, a `reason`, and a `hint` saying what would allow it. `decide()` is the only code that decides anything, and what operations offer comes from asking it about each field and verb, so a model is never offered what the policy refuses. ## Recipes Source: Complete policies for common jobs. Each one starts from nothing allowed and adds only what the job needs. Bind each with `policy.bind(app)` before giving it to a client, except the first, which needs no binding. ### A dashboard or report Reads everything the token can, and changes nothing. ```python from pyquickbase import Policy policy = Policy.read_only() ``` ### A support agent Reads tasks without costs, a page at a time, and changes only their status and notes. ```python from pyquickbase import Policy, allow, deny, fields policy = Policy( classes={"internal": ["Tasks.Amount", "Tasks.Code"]}, rules=[ allow.read("Tasks", max_paging_depth=50), allow.update("Tasks", ["Status", "Notes"]), deny.all(fields.classed("internal")), ], ) ``` ### A data-entry bot Adds tasks and can't read, change, or delete anything. Writing a field doesn't need read access to it. ```python from pyquickbase import Policy, allow policy = Policy(rules=[allow.insert("Tasks", ["Title", "Status", "Due Date", "Project"])]) ``` ### One project's assistant Reads and updates only one project's tasks, and deletes at most three records at a time. ```python from pyquickbase import F, Policy, allow, limit policy = Policy( rules=[ allow.read("Tasks", max_paging_depth=100), allow.update("Tasks", ["Status", "Notes", "Due Date"]), allow.delete("Tasks", max_records_per_delete=3), limit.rows("Tasks", F("Project") == "Roof"), ] ) ``` `limit.rows()` doesn't check an update by `Record ID#`, so pair it with a role whose [record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules) do. ### A schema assistant Adds and changes fields on one table, but can't delete fields or tables, or make formulas. ```python from pyquickbase import Policy, allow policy = Policy( rules=[ allow.read("Tasks"), allow.schema("Tasks", "add_field", "change_field"), ] ) ``` See [Schema changes](https://matthew-williams-solutions.github.io/pyquickbase/policies/schema/) for every verb, and which table each one is granted on. ### A nightly sync Upserts records by a unique code, in batches of up to 500 rows, with a rule of your own. ```python from pyquickbase import Decision, Policy, WriteRequest, allow, fields class Batches: def decide(self, request): if isinstance(request, WriteRequest) and (request.row_count or 0) > 500: return Decision(allowed=False, verb="insert", reason="Up to 500 rows at a time") return None policy = Policy( rules=[ allow.read("Tasks", ["Code"]), allow.insert("Tasks", fields.all()), allow.update("Tasks", fields.all()), Batches(), ] ) ``` An upsert needs both `insert` and `update`. See [Rules of your own](https://matthew-williams-solutions.github.io/pyquickbase/policies/custom-rules/). ## What a policy doesn't do Source: - **It can't see copies of a value.** An address field shows its subfields, and a value pasted into another field is just text. Check what each allowed field holds. - **A write can reveal a value.** An update reports a record as unchanged when a field already held the value it was given, so a client that may write a field can test what it holds. - **It limits this client, not the token.** Code that can create another client, or call the API directly, can get around it. To limit that, the token's role has to do it. - **It doesn't cover what Quickbase does next.** A pipeline or automation can still change other tables after an allowed write. - **It can't limit updates by record.** An update only says which record to change, so checking its contents would mean reading it first, and it could change in between. Use a role with [record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules), which Quickbase enforces itself. ### From an agent's side - **A policy complements the token's role, and doesn't replace it.** Quickbase still enforces the role on everything it allows. - **A value can be copied without a formula.** A class follows formulas, lookups, and summaries, but not a value someone pasted into another field, or an address's subfields. - **Filtering and sorting reveal values.** A model that may filter on a field can find out what it holds by seeing which records match, even if the field isn't returned, so `allow.read()` takes its own `filter` and `sort`. - **Limits apply per query.** `max_paging_depth` stops one query from returning a whole table, but a model can still make more queries. The policy's `max_paging_depth` is how far paging can reach, counting `skip`, while an operation's `max_records_per_call` is the size of each page, so set the policy's number as high as you want a model to page. - **`Policy.read_only()` promises only that it changes no records.** It still runs reports, which adds to their usage count in Quickbase, and formulas. - **Formulas reach further than the table you run them on.** A formula can read other tables with `GetRecord()` and `GetRecords()`, and can return the realm and the token owner's email. Don't run formula text from a model or another person, even on a read-only client. See the [warning in Formulas](https://matthew-williams-solutions.github.io/pyquickbase/guides/formulas/#running-a-formula). - **They can't keep updates to certain records.** `limit.rows()` covers queries and deletes, but an update names its record by ID. Use a role with [record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules), and [fix the scope](https://matthew-williams-solutions.github.io/pyquickbase/agents/untrusted-input/#filters-from-a-model) of what it reads and writes in your code. - **They aren't a sandbox.** They apply to one client. If an agent can run code of its own, it can create another client with the same token, call the API directly, or reach inside the client to remove them. ## Overview Source: Every public class, function, and type, with a tested example for both clients. Each page also has a plain Markdown copy for AI tools. Everything here is public, as [Stability and versioning](https://matthew-williams-solutions.github.io/pyquickbase/stability/) describes, and modules starting with `_` are private. The async classes have the same methods as the sync ones, which you `await`, so they're described together.
- **Core** The client, and the apps, tables, and reports you reach through it. - [Quickbase](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) Create and close a client, and call any endpoint - [App](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) An app's details, its tables, and schema changes with a plan - [Table](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) Read and write records and files, and change a table's fields - [Report](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) Run reports built in the Quickbase interface - **Data** The conditions you send, and what comes back. - [Queries](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/) Build conditions with `F`, or from plain data - [Records and results](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/) What reads and writes return, files, previews, and summaries - [Metadata](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/) What describes apps, tables, fields, relationships, and reports - [Field types](https://matthew-williams-solutions.github.io/pyquickbase/field-types/) What to write to each field type, and what it reads as - **Schema** Changes to tables, fields, and relationships, checked before they're made. - [Schema changes](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/) The changes, and the plans that check them - [SchemaOperation](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/) One schema operation offered to a model - **Policies** What a client may read and change. - [Policies](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/) Write a policy, bind it, and ask what it allows - [Rules](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/) Allow, deny, and limit tables, fields, and rows - **AI agents** Operations and results for a model's tool calls. - [Operation](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/) One table operation offered to a model - [Tools for models](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/) Results and errors as plain data for a tool call - **Errors and retries** What the SDK raises, and how a client retries. - [Errors](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/) Everything the SDK raises, and what each one holds - [Retries](https://matthew-williams-solutions.github.io/pyquickbase/reference/retries/) How a client retries rate limits and failed connections
### Every public name {#names}
- [`allow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#allow) - [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) - [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) - [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) - [`AppInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppInfo) - [`AsyncApp`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) - [`AsyncOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/) - [`AsyncQuickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) - [`AsyncRecordIterator`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordIterator) - [`AsyncReport`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) - [`AsyncSchemaOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/) - [`AsyncTable`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) - [`AuthenticationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#AuthenticationError) - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError) - [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) - [`ColumnMap`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#ColumnMap) - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError) - [`ConfirmationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfirmationError) - [`CreateField`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#CreateField) - [`CreateLookupField`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#CreateLookupField) - [`CreateRelationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#CreateRelationship) - [`CreateSummaryField`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#CreateSummaryField) - [`CreateTable`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#CreateTable) - [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) - [`DeleteField`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#DeleteField) - [`DeletePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#DeletePreview) - [`DeleteRelationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#DeleteRelationship) - [`DeleteTable`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#DeleteTable) - [`deny`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#deny) - [`DerivedKind`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#DerivedKind) - [`Expression`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#Expression) - [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F) - [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) - [`FieldChange`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FieldChange) - [`FieldRef`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#FieldRef) - [`FieldRefs`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#FieldRefs) - [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) - [`FieldSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#FieldSelector) - [`FieldUsage`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#FieldUsage) - [`FileAttachment`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileAttachment) - [`FileDownload`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileDownload) - [`FileFormat`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileFormat) - [`FileLike`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileLike) - [`FileRowError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileRowError) - [`FileRows`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileRows) - [`FileTooLargeError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#FileTooLargeError) - [`FileUpload`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileUpload) - [`FileVersion`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileVersion) - [`FormulaResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FormulaResult) - [`InvalidResponseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidResponseError) - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError) - [`Layer`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Layer) - [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit) - [`Named`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Named) - [`NetworkError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NetworkError) - [`NotFoundError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NotFoundError) - [`Offers`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Offers) - [`Operation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/) - [`OperationName`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#OperationName) - [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) - [`parse_where`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#parse_where) - [`Period`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Period) - [`PeriodChange`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#PeriodChange) - [`PeriodUnit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#PeriodUnit) - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError) - [`PlannedChange`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#PlannedChange) - [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy) - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError) - [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError) - [`Quickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) - [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) - [`RateLimitError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#RateLimitError) - [`ReadAction`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#ReadAction) - [`ReadRequest`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#ReadRequest) - [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record) - [`RecordChange`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordChange) - [`RecordIterator`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordIterator) - [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship) - [`Report`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) - [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) - [`RequestTimeoutError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#RequestTimeoutError) - [`RetryPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/retries/#RetryPolicy) - [`Risk`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#Risk) - [`Role`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Role) - [`RowError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RowError) - [`Rule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Rule) - [`SchemaAction`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaAction) - [`SchemaChange`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChange) - [`SchemaChangeError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaChangeError) - [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult) - [`SchemaMismatchError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaMismatchError) - [`SchemaOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/) - [`SchemaOperationName`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#SchemaOperationName) - [`SchemaPlan`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaPlan) - [`SchemaVerb`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#SchemaVerb) - [`ServerError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ServerError) - [`SettingChange`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SettingChange) - [`StatisticName`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#StatisticName) - [`SummaryFunction`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SummaryFunction) - [`SummaryGroups`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryGroups) - [`SummaryRow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryRow) - [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) - [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) - [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) - [`TableSchema`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableSchema) - [`TableSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#TableSelector) - [`tool_error`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#tool_error) - [`tool_result`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#tool_result) - [`ToolErrorCode`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#ToolErrorCode) - [`TooManyRecordsError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#TooManyRecordsError) - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError) - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError) - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError) - [`UnsupportedReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnsupportedReportError) - [`UpdateField`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#UpdateField) - [`UpdatePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#UpdatePreview) - [`UpdateTable`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#UpdateTable) - [`User`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#User) - [`Verb`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Verb) - [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict) - [`where_schema`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_schema) - [`WhereCondition`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#WhereCondition) - [`WriteAction`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#WriteAction) - [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError) - [`WritePermissionError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WritePermissionError) - [`WriteRequest`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#WriteRequest) - [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult)
## Field types Source: These are the values `create_field()` and `ensure_field()` accept as `type`, and what each type takes and returns. Quickbase refuses any other type with `BadRequestError`. To create fields, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). ```python tasks = qb.app("bq1234567").table("Tasks") tasks.create_field("Start Date", "date") tasks.create_field("Priority", "text-multiple-choice", choices=["Low", "High"]) ``` ### Text types | `type` | Holds | Write | Reads as | Empty reads as | Takes a formula | |---|---|---|---|---|---| | `text` | One line of text | `str` | `str` | `""` | Yes | | `text-multiple-choice` | One choice | `str` | `str` | `""` | Not tested | | `text-multi-line` | Several lines of text | `str` | `str` | `""` | No | | `rich-text` | Text containing HTML | `str` | `str` | `""` | Not tested | | `multitext` | Several choices | `list[str]` | `list[str]` | `[]` | Not tested | | `email` | An email address | `str` | `str` | `""` | Yes | | `url` | A link | `str` | `str` | `""` | Yes | | `phone` | A phone number | `str` | `str`, formatted by Quickbase: `5551234567` reads as `(555) 123-4567` | `""` | Not tested | **An empty text field reads as `""`, not `None`**, so check for it with `if not value`. ### Number types | `type` | Holds | Write | Reads as | Empty reads as | Takes a formula | |---|---|---|---|---|---| | `numeric` | A number | `float` or `int` | `float` | `None` | Yes | | `currency` | An amount of money | `float` or `int` | `float` | `None` | Yes | | `percent` | A percentage: write `0.5` and it reads as `0.5` | `float` or `int` | `float` | `None` | Yes | | `rating` | A rating | `float` or `int` | `float` | `None` | Yes | | `duration` | A length of time, in milliseconds | Whole milliseconds as an `int`, or a `timedelta`. A fraction raises `InvalidValueError`, as Quickbase would drop it | `int` | `None` | Yes | **In a query, numbers mean the same as when writing**, though Quickbase reads them differently, so `F` converts them: `F("Done") >= 0.5` is 50%, and `F("Budget") > timedelta(hours=8)` or `> 28_800_000` is 8 hours. A hand-written string is sent as written, where Quickbase compares a percent in whole percent, `{f.GTE.'50'}`, and needs a duration as text, `{f.GT.'8 hours'}`. **A blank number can read as 0 in a formula**, so `IsNull([Hours])` was false for an empty Hours field made through the API. See [API notes](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#formulas). ### Date and time types | `type` | Holds | Write | Reads as | Empty reads as | Takes a formula | |---|---|---|---|---|---| | `date` | A date | `date` | `date` | `None` | Yes | | `datetime` | A date and time. The field then reports its type as `timestamp` | Timezone-aware `datetime` | Timezone-aware `datetime` | `None` | Yes | | `timestamp` | The same as `datetime` | Timezone-aware `datetime` | Timezone-aware `datetime` | `None` | No: use `datetime` | | `timeofday` | A time of day | `str`, such as `13:30:00` | `str` | `""` | Yes | ### Other types | `type` | Holds | Write | Reads as | Empty reads as | Takes a formula | |---|---|---|---|---|---| | `checkbox` | True or false | `bool` | `bool` | `False`, so empty and unchecked look the same | Yes | | `user` | One Quickbase user | A `User`, or `{"id": ...}` | `User` | `None` | Not tested | | `multiuser` | Several users | A list of `User`, or of `{"id": ...}` | A list of `User` | `[]` | Not tested | | `file` | An attachment | [`FileUpload`](https://matthew-williams-solutions.github.io/pyquickbase/guides/files/) | `FileAttachment` | `None` | Not tested | | `address` | An address, as display text | Read-only: write its subfields | `str` | `""` | Not tested | A query only returns a table's default fields unless you pass `select`, and built-in fields such as `Record Owner` may not be among them. ### Types you can't create Fields of these types exist, but `create_field()` refuses them: - `recordid` is the built-in `Record ID#` field. - `dblink` is the list of child records that a [relationship](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/#linking-tables-with-a-relationship) adds to the parent. ### Field settings `properties` holds any other settings, and is sent as it is. Quickbase refuses a key it doesn't know with `BadRequestError`, rather than ignoring it. These were accepted and kept, and Quickbase has more: | Key | Tested on | Example | |---|---|---| | `decimalPlaces` | `numeric` | `2` | | `doesTotal` | `numeric` | `True` | | `currencySymbol` | `currency` | `"€"` | | `maxLength` | `text` | `10` | | `defaultValue` | `text` | `"Open"` | | `comments` | `text` | `"A note about this field"` | | `numLines` | `text-multi-line` | `3` | | `appearsAs` | `url` | `"Link"` | | `allowNewChoices` | `text-multiple-choice` | `False`, so a value outside `choices` is refused. It's on by default | | `sortAsGiven` | `text-multiple-choice` | `True` | ```python tasks = qb.app("bq1234567").table("Tasks") budget = tasks.create_field("Budget", "numeric", properties={"decimalPlaces": 2}) print(budget.decimal_places, budget.raw["properties"]) ``` `decimalPlaces` only changes how a number is displayed: Quickbase still stores `1.5` in a field set to 0 places. `Field.raw` holds everything Quickbase returned for a field, including settings the SDK has no attribute for. A few settings go beside `properties` rather than inside it, and Quickbase refuses them inside it. `create_field()`, `update_field()`, and `ensure_field()` take them as arguments: | Argument | Quickbase's name | What it does | |---|---|---| | `help_text` | `fieldHelp` | Help shown beside the field on forms. `Field.help_text` reads it back | | `bold` | `bold` | Shows the field's values in bold | | `no_wrap` | `noWrap` | Keeps the field's values on one line | | `find_enabled` | `findEnabled` | Includes the field in Quickbase's search box | | `appears_by_default` | `appearsByDefault` | Returns the field from a query without `select` | | `add_to_forms` | `addToForms` | Adds the field to the table's forms. Only `create_field()` takes it, as Quickbase never reports it | ```python tasks.create_field("Priority", "text", help_text="High, Medium, or Low", bold=True) tasks.update_field("Priority", find_enabled=False) ``` ## Stability and versioning Source: This policy applies from version 1.0. Until then, anything may change in any release. From 1.0, pyquickbase follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Breaking changes come only in a major version, and anything removed first gets a `DeprecationWarning` in a minor release. ### What's covered - Every name in `pyquickbase.__all__` and `pyquickbase.errors`, with its documented methods, signatures, return types, and defaults. - The `pyquickbase` command and its options. - The promise that a client with `Policy.read_only()` changes no records. It can still run reports, which adds to their usage count in Quickbase, and formulas. - The attributes of `User`, `FileAttachment`, and `FileVersion`, which file and user fields read as. A value a policy leaves out, such as an email address, is None. - A `Policy` refusing whatever its rules don't allow, a deny winning over any allow, and the policy leaving out of a response whatever it hides. A later version can add a new `Verb`, `WriteAction`, or `ReadAction`, or a new attribute on `Decision`, `WriteRequest`, or `ReadRequest`, so have a rule of your own ignore an action it doesn't recognize. - The keys of what `tool_result()` and `tool_error()` return, how they write each kind of value, and the codes in `ToolErrorCode`, along with each code's `changed` and `retryable`. A later version can add a key or a code, so have your tool handle a code it doesn't recognize. The wording of `message` and `hint` isn't covered. - The operation names in `OperationName`, the arguments each one's schema takes, and how `run()` reads them. A later version can add an operation or an optional argument. The schema's descriptions aren't covered. ### What isn't covered - Modules and names starting with `_`. - Constructors of objects the client creates for you, such as `App`, `Table`, and `Report`. Use `qb.app()`, `app.table()`, and `table.report()` instead. - The wording of error messages and `repr`s, so don't parse them. - Quickbase's own behavior, including any change it makes to its responses, and the contents of `raw` attributes. - Which metadata keys a policy keeps, as Quickbase's responses aren't covered either. ### Minor and patch releases A minor release can add features, optional arguments, and support for more field types. A patch release only fixes bugs. ### Dependencies and Python versions A Python version is dropped only after it reaches end of life, and only in a minor release. pip won't install a release on a Python version it doesn't support, so older Python versions keep the last release that worked. `httpx` stays below version 1 until a major version of pyquickbase, because `http_client` exposes it. ## Quickbase API notes Source: What this SDK relies on, and how far each point has been confirmed. Much of the developer portal renders in the browser and has no published spec that we've found, so check anything not listed here against a real app before depending on it. Questions we haven't answered yet are in the [roadmap](https://github.com/matthew-williams-solutions/pyquickbase/blob/main/ROADMAP.md#open-questions). ### Confirmed | Behavior | Source | |---|---| | Headers: `QB-Realm-Hostname` (full hostname), `Authorization: QB-USER-TOKEN ` | Accepted by the live API | | Error body: JSON with `message` and `description` | Live API, 2026-11-02 | | `qb-api-ray` response header holds a request ID | Live API, 2026-11-02 | | 100 requests per 10 seconds per user token for most endpoints; some differ | [Rate limiting docs](https://help.quickbase.com/docs/rate-limiting-overview.md) | | On a 429, read `retry-after`; retry only for 429 | Same page | | Operators: CT, XCT, HAS, XHAS, EX, TV, XTV, XEX, SW, XSW, BF, OBF, AF, OAF, IR, XIR, LT, LTE, GT, GTE, uppercase only | [Query docs](https://help.quickbase.com/docs/components-of-a-query) | | Query dates: `yyyy-MM-dd` or milliseconds, not date-time strings | Same page | | Query `metadata` has `numFields`, `numRecords`, `skip`, `totalRecords`, and `top` when it was sent | Live API, 2026-11-02 | | Omitting `options` or sending `top: 0` returns all matches in a small table; a `skip` past the end returns no records | Live API, 2026-11-02 | | **A query can return fewer than `top` records before the end.** With rows of 30,000 characters, `top: 3000` returned 831 at a time, so advance by the number returned | Live API, 2026-11-02 | | Paging uses offsets, so deleting 50 earlier rows while reading 600 **silently skipped 50 unread records** | Live API, 2026-11-02 | | 50,000 rows: inserted in 12 seconds, read in 8, deleted in one request in 1 second. Each came back exactly once, and a sort with ties stayed stable across pages of 999 and 7,000 with Record ID# as the last sort key | Live API, 2026-11-02 | | `EX` on text ignores case | Live API, 2026-11-02 | | `AND` and `OR` must be uppercase; whitespace around them is accepted | Live API, 2026-11-02 | | Unquoted values such as `{3.EX.5}` work the same as quoted ones | Live API, 2026-11-02 | | `EX ''` matches empty fields and `XEX ''` matches non-empty ones | Live API, 2026-11-02 | | On a date field, `LT`, `LTE`, `GT`, and `GTE` match the same records as `BF`, `OBF`, `AF`, and `OAF` | Live API, 2026-11-02 | | Timestamps can be compared with `AF` and `BF` using milliseconds | Live API, 2026-11-02 | | Timestamps can also be compared with `AF` and `BF` using a date, such as `2027-03-01` | Live API, 2026-11-04 | | With no `select`, only fields in the table's default set return, and Record ID# may not be one | Live API, 2026-11-02 | | Timestamps return as `2026-10-21T11:49:56Z`, dates as `2026-10-18` | Live API, 2026-11-02 | | A bad token, a nonexistent app, and an app the token can't reach all return 401 with the same message, `Access denied: User token is invalid`, so they can't be told apart | Live API, 2026-11-05 | | A nonexistent app or table returns 401 with a valid token, not 404. An ID of the wrong shape, such as `abc` or one that's too long, returns 400, `Invalid request` | Live API, 2026-11-02 | | `GET /tables/{id}/relationships` lists only relationships where the table is the child. A parent table returns none | Live API, 2026-11-02 | | Creating a table with a name already in the app, ignoring case, returns 400, `Invalid input` | Live API, 2026-11-02 | A bad realm or app can return 400 rather than 401 or 404, even with an invalid token, so don't assume a 400 means the request body was wrong. ### Query pitfalls Tested on 2026-11-02 against a small sandbox app. These matter because many mistakes return a plausible result instead of an error. | `where` clause | Result | |---|---| | Lowercase or unknown operator: `{6.ex.'x'}`, `{6.NOPE.'x'}` | **Behaves like `CT`** and returns matches | | Unknown field: `{999999.EX.'x'}` | No records, no error | | Anything but a number as the field: `{'Title'.EX.'x'}`, `{Title.EX.'x'}`, even `{'No Such'.EX.'x'}` | **No error, and it matched records holding the value.** The SDK's validator refuses these | | Trailing join: `{6.EX.'x'}AND` | No error | | Lowercase join, missing brace, or free text | 400, `Criteria is invalid.` | **Quoting values.** Tested with a temporary record containing `O'Brien`, a backslash, and a brace: | Text in a value | Result | |---|---| | `\'` or an unescaped `'` | Matches a stored apostrophe, but the unescaped form is ambiguous, so `F` and the string validator use `\'` | | `''` | Doesn't match a stored apostrophe | | `\\` | Matches a stored backslash; a single `\` before another character doesn't | | `{` or `}` | 400, `Criteria is invalid.`, so values containing braces can't be searched for | **A value can break out of its clause.** With `zz'}OR{3.GT.'0` as the value, the query matched every record whether the quotes were left alone, written as `\'`, or written as `''`. So `F` rejects braces in values. The string validator can't tell a breakout from a genuine second condition, so use `F` for any text you don't control. **A quoted value can still be query syntax**, with no way to escape it. Tested on 2026-11-02: | Value | Result | |---|---| | `a OR b` | **Matches `a` and `b`, and not a stored `a OR b`.** Also with `EX`, `CT`, `SW`, `TV`, and their opposites, on text, numeric, and date fields | | `x AND y` | Matches nothing, not even a stored `x AND y` | | `OR` or `AND` alone, `x OR`, `OR y` | The word is dropped: `x OR` matches `x`, and `OR` alone matches nothing | | What splits a value | **Only uppercase `OR` and `AND` between spaces or tabs.** `Or`, `x,OR,y`, `x-OR-y`, `xOR y`, and a line break or non-breaking space around it don't | | The same words in lowercase | Literal text. Comparisons ignore case, so `x or y` finds a stored `x OR y`, including on multi-select fields. This is what `F` sends | | `_FID_6`, in any case | **Compares with field 6, so on field 6 it matches every record.** `DELETE /records` with `{6.EX.'_FID_6'}` deleted every record. `F` rejects these | | `_FID_6x`, `_FID_06`, `_FID_ 6`, `_FID_6 x`, `\_FID_6` | The same. A leading space, other text, or `\\` before `_FID_` makes it literal, so there's no way to search for this exact text | | `_ask1_` | 400, `Incomplete reports not supported`. `_ASK1_` is literal | | `_curuser_`, on a text field | Literal | | `_curuser_`, on a `multiuser` field | **The token's owner**, with `EX`, `TV`, and `HAS`, ignoring case and surrounding spaces | | `_curuser_`, on a `user` field | **The token's owner** with `TV`, ignoring case and surrounding spaces. Nothing with `EX` | | Matching a `user` field | `TV` and `XTV` with an email or user ID, ignoring case, and with `''` for empty. **`EX` matched nothing**, including the field's real user, and so did `CT` and `HAS`. **`XEX` matched every record, including that user's.** `EX` and `XEX` with `''` do work. `F` sends `TV` and `XTV` for `==` and `!=` here | | Matching a `multiuser` field | `EX`, `TV`, or `HAS` with an email or user ID, and `CT` with a name | | Text on a date or timestamp field | Read as a date: `today`, `yesterday`, and `1 day ago` matched. Text it can't read, such as `garbage`, `last week`, or `now`, **matched nothing, with no error** | | `today` and `yesterday` in a query, and `Today()` in a formula | Follow Quickbase's date rather than the client's, which can differ near midnight: at 00:55 EST on 2026-11-05, `Today()` returned `11-04-2026`. Live API, 2026-11-05 | | `TRUE`, `NULL`, `today`, `*`, `%`, `_` on a text field | Literal | | A number on a `percent` field | **Compared as whole percent**, though a percent reads and writes as a fraction: `{f.EX.'100'}` matched the 45 records at 100%, and `{f.EX.'1'}` matched none. `25`, `25.00`, and `25%` matched the records at 25%, and `0.25` matched none. `F` sends a number times 100. Live API, 2026-11-05 | | A number on a `duration` field | **Matches nonsense, with no error.** On durations from 30 minutes to 20 hours, `GT 0.9999` matched every record with a value and `GT 1` matched none, and so did 8 hours as `8`, `480`, `28800`, or `28800000`. Live API, 2026-11-05 | | Text on a `duration` field | Read as a duration: `6:00:00`, `6:00`, `6 hours`, `360 minutes`, `21600 seconds`, `0.25 days`, and `21600000 milliseconds` each matched the 7 records of 6 hours. **`N milliseconds` is exact**: `GT '71999999 milliseconds'` matched the one record of 20 hours. `ms` matched nothing. Negative values and `0 milliseconds` work, and `''` matches empty. `F` sends `N milliseconds`. Live API, 2026-11-05 | ### Writes Tested on 2026-11-02 against temporary tables in a sandbox app. | Behavior | Result | |---|---| | A row fails | The request returns 207, other rows are written, and `metadata.lineErrors` maps the row number, starting from 1, to its messages | | `{"value": null}` | **Silently ignored**, leaving the old value | | `{"value": ""}` | Clears text, date, timestamp, numeric, choice, and checkbox fields | | Row with an existing Record ID# | Updates it, or reports it in `unchangedRecordIds` if nothing changed | | Row with a missing Record ID#, with or without `mergeFieldId` | Row error, and no record is created | | Row with an existing Record ID#, without `mergeFieldId` | Updates it, as it does with `mergeFieldId: 3`. Live API, 2026-11-05 | | `mergeFieldId` of `"3"` or `3.0` | Accepted, as field 3. Live API, 2026-11-05 | | A row's field key with padding, such as `"07"`, `" 7"`, or `"7 "` | **Accepted as field 7, and the value is written.** So check field IDs only in their usual form. Live API, 2026-11-05 | | `keyFieldId` in `POST /tables/{id}` | 200, and **ignored**, so the key field stays 3. A table's key field can't be changed through the API. In the UI, select the field on the table's fields page and click **Set key**. Live API, 2026-11-05 | | Custom key field: a row with an existing key value, without `mergeFieldId` | **Updates that record, and creates nothing.** A batch can do both, leaving `createdRecordIds` shorter than the rows. So `insert()` refuses these tables. Live API, 2026-11-05 | | Custom key field: a row with a new key value, without `mergeFieldId` | Creates a record. Live API, 2026-11-05 | | Custom key field: a row without the key field, without `mergeFieldId` | Row error, `Missing value for required field`, though it wasn't set as required. Live API, 2026-11-05 | | Custom key field: a row with a Record ID#, without `mergeFieldId` | **500**, `You cannot include the record ID if it is not the key field`. Live API, 2026-11-05 | | Custom key field: a row with a Record ID# and `mergeFieldId: 3` | Updates that record, and a Record ID# that doesn't exist is a row error, as on any table. Live API, 2026-11-05 | | `mergeFieldId` on a unique field, value doesn't match | **Creates a record** | | `mergeFieldId` on a non-unique field, or a row without the merge field | 400 for the whole request | | Unknown field, formula field, built-in field | Row error | | A batch where every row sets the same one field, and a row's value is empty | **That row is silently skipped, with no error**, so `createdRecordIds` no longer lines up with the rows. A batch of one row, or using two or more fields, creates it. `insert()` and `upsert()` refuse these rows | | A value outside a field's choices | Accepted when `allowNewChoices` is on, which is the default for a field made through the API, otherwise a row error | | The row error for an invalid choice | `The field with ID '7' has an invalid choice.` Confirmed again in a three-row import: 207, `raise_for_status()` passes, and the other two rows are saved | | Empty `data` | 400 | | `DELETE /records` without `where`, or with an empty one | 400 | | `DELETE /records` with only whitespace as `where` | **200, with `numberDeleted: 0`**, and nothing deleted | | `DELETE /records` with a malformed `where` | **`numberDeleted: 0`, no error** | | `DELETE /records` with a lowercase operator | **Acts like `CT`**, so it can delete more than intended | | `DELETE /fields` with `{"fieldIds": [...]}` | Partial failures are listed in `errors` beside `deletedFieldIds` | | `POST /fields` with `unique` or `required` | 400. Set them afterward with `POST /fields/{id}` | | Reading a cleared date or timestamp | Returns `""`, and a cleared numeric returns null | | Checkbox queries | `{id.EX.'true'}`, `{id.EX.'1'}`, and `{id.TV.'true'}` match the same records | ### Payments and repeated events Tested on 2026-11-02 with the SDK against temporary tables in a sandbox app. | Behavior | Result | |---|---| | 8 simultaneous `POST /records` with the same new value on a unique `mergeFieldId` | All succeeded, and **exactly 1 record was created** | | 40 simultaneous writes of different values | All landed | | Repeating the same write | Reported as unchanged, with no new record | | `insert` of a value already in a unique field | Row error, and no new record | | `Decimal` amounts from `0.01` to `99999999.99` in a currency field | Read back to the exact cent, through `Decimal(str(value))` | | `decimalPlaces: 0` on a numeric field | **Display only.** `1999.5` was stored and read back as `1999.5`. Whole numbers return as floats, such as `1999.0` | | A foreign key's properties | `foreignKey: true` and `decimalPlaces: 0`. A lookup of a foreign key has `foreignKey: false` and `decimalPlaces: 0`. A plain numeric field has no `decimalPlaces` unless it was set | | Whole numbers up to 9,007,199,254,740,991 in a numeric field | Read back exactly | ### Reports Tested on 2026-11-02 against a temporary table in a sandbox app, with table, ask-the-user, summary, and chart reports built in the UI. The API can't create reports. | Behavior | Result | |---|---| | `GET /reports?tableId=` | A list of the same objects as `GET /reports/{id}?tableId=`: `id`, `name`, `type`, `description`, `query`, `properties`, `incomplete`, `usedCount`, and `usedLast` once used | | Report IDs | Numeric strings, **unique only within a table**. A new table gets `1` (List All) and `2` (List Changes) | | Deleted report IDs | Not reused. Checked by hand in the UI on 2026-11-04, so cached report details won't find a different report under an old ID | | Report names | **Can repeat within a table** | | `POST /reports/{id}/run?tableId=` | Needs no body. Returns `fields`, `data`, and `metadata`, as a query does | | `top` and `skip` | Must be query parameters. **In the body they're silently ignored, and every record returns** | | `top` of 0 or less, or a negative `skip` | 400, although a query accepts `top: 0` | | `skip` past the end | No records, no error | | A report ID with leading zeros, such as `01` | Runs the same report | | Missing `tableId` | 400 | | Unknown table | 401 for an ID that never existed, 404 for a dropped table | | Unknown or non-numeric report ID | 404 | | The report's filter, sort, and columns | Applied. Rows hold only the report's columns | | A report whose columns leave out Record ID#, including the default `List All` | Rows have no field 3 | | A table report with Record ID# as a column, even in second position | `fields` uses the real ID, 3, with type `recordid`, unlike a summary report's positions. All 1,200 rows matched a query's IDs | | A table report with a formula column | `fields` uses the formula field's real ID and its type, such as `numeric`. All 1,200 values matched a query | | A sort with ties, 400 records on each of 3 values | Ties came back in Record ID# order. Six full runs, with page sizes of 7, 37, 100, and 1,000, gave the same order, with nothing repeated or missing. Editing a tied record's other field didn't move it | | A sort with ties, 2,000 records on each of 3 values | The same, on 2026-11-04. 13 full runs of 6,000 records, with no `top` and page sizes from 37 to 6,000 in a shuffled table, all gave Record ID# order within each value. This is what was seen, not a promise from Quickbase | | `top` omitted, in a 50,000-record table | Returned every record, and `metadata` has no `top` | | Changing a saved report's type | The UI doesn't offer this | | Running a report | **Adds 1 to `usedCount`.** A GET doesn't, a 400 for a bad `top` or `skip` doesn't, and records aren't modified. An earlier test saw a failed run add 1 too, for a reason not pinned down | | Ask-the-user report | `incomplete: true`, and running it returns 400, `Incomplete reports not supported` | | Summary and chart reports | **Same response shape as a table report, but `fields` IDs are column positions starting at 2.** The second column has ID 3, the Record ID# field in a table report, so nothing in the response tells them apart. Only `type` on the report does | | Chart category values | Dates return as text, `01-01-2026`, not `yyyy-MM-dd` | | Summary report column labels | Each total gets the field's label and its kind, so a sum and an average of one field are `Hours (tot)` and `Hours (avg)`. A chart's value column is `Hours (tot)` too. Live API, 2026-11-05 | | `top` and `skip` on summary and chart reports | Page the rows as they do on a table report, and `totalRecords` counts every row. Live API, 2026-11-05 | | Renaming a column in a report | The UI doesn't offer this | ### File attachments Tested on 2026-11-02 against a temporary table in a sandbox app, using a new `file` field with its default settings. | Behavior | Result | |---|---| | Upload | `POST /records` with `{"value": {"fileName": ..., "data": }}` | | Download | `GET /files/{table}/{record}/{field}/{version}`. **The body is base64**, whatever `Content-Type` says. Bytes round-trip exactly, including all 256 values | | File name in a download | `Content-Disposition: attachment; filename*=utf-8''` | | `Content-Type` of a download | Based on the file name's extension: an `.html` name gives `text/html`, with a base64 body | | Version `0` | The latest, **for `DELETE` as well as `GET`** | | Each upload | Adds a version, even with identical contents, and reports the record as updated | | Version limit | A new field has `maxVersions: 3`. **The oldest is silently removed**, and its download returns 404 | | `maxVersions` values | 1 to 1,000 were accepted, and 0 or a negative number returns 400. At 1, 2, and 5, only that many newest versions were kept. **A field set to 1 leaves `maxVersions` out of its properties** | | `versionMode` | `keeplastversions` by default, which applies the limit. **`keepallversions` ignores `maxVersions`**: it kept all 150 uploads, and all 5 with `maxVersions: 2`. No cap was found in that range | | Lowering `maxVersions` | Nothing is removed until the next upload, which then trims to the new limit. Raising it doesn't bring back removed versions | | Version numbers | Not reused or renumbered after a delete or removal | | `DELETE` a version | Returns that version's details. Deleting the last one keeps the record and empties the field | | `{"value": ""}` or `{"value": null}` on a file field | **No error and no change**, reported in `unchangedRecordIds`. On insert, creates a record with no file | | `{"value": "text"}`, or a value without `data` | Row error | | A value without `fileName` | **No error, and creates a record with no file** | | Empty `fileName` | Row error | | Invalid base64 | **Accepted, and the file holds garbage**, with no row error, also inside a batch | | Empty `data` | Accepted, as an empty file | | A file value sent to a text field | **Stores the file name and discards the contents** | | File names | **Stored as sent, including `../../etc/passwd`, `C:\\x\\y.txt`, and line breaks.** A NUL byte cuts the name short. No extension was refused, including `.exe` and `.html` | | Reading a file field | `{"url": "/files/...", "versions": [...]}`, with a relative `url`. Empty is `{"url": "", "versions": []}` | | Each entry in `versions` | `versionNumber`, `fileName`, `uploaded`, and `creator`, **which includes the uploader's email address** | | Missing record, field, or version | 404 | | A non-file field | 400 | | Unknown table | 400, `Invalid usertoken`, unlike other endpoints' 401 | **Request size limit.** `POST /records` accepted a body of exactly 41,943,040 bytes (40 MiB) and returned 413, `request entity too large`, at one byte more. It's a limit on the whole request: two 25 MB files in one request returned 413, and two 15 MB files succeeded. It applies without files too: 1,500 rows of 30,000 characters returned 413 and wrote nothing. The largest file that fits was 31,457,208 bytes. Downloads worked up to that size. The limit is the same elsewhere. On 2026-11-04, `POST /records`, `POST /records/query`, `DELETE /records`, `POST /formula/run`, `POST /reports/{id}/run`, `POST /fields`, and `POST /tables` each got past it with a body of exactly 41,943,040 bytes and returned 413 at one byte more. Only one realm has been tried. ### Formulas Tested on 2026-11-02 against temporary tables in a sandbox app. | Behavior | Result | |---|---| | `POST /formula/run` | Takes `formula` and `from`, and `rid` if the formula uses fields. Returns `result`, `resultType`, and `fieldTypeApplied` | | `result` | **Always a string**, whatever the type | | `resultType` values seen | `text`, `number`, `bool`, `date`, `date/time`, `timeofday`, `duration`, `textlist`, `user`, `null` | | Booleans | **`"1"` and `"0"`**, so `"0"` is a truthy string in Python | | Numbers | Rounded for display: `1/3` returns `"0.3333333"` | | Dates and timestamps | **Display text in the app's format, with no time zone:** `03-04-2026` and `03-03-2026 09:06 PM`, where the stored time was `2026-03-04T05:06:07Z`. A query returns the same field as ISO 8601 | | Durations | Display text: `Days(3)` returns `"72:00"` | | Empty values | `""`, for any type | | A blank formula | `""` returns 400. **Whitespace returns 200 with an empty `null` result** | | A formula using a field, with no `rid` | 400 | | Missing record | 404, even for a formula that uses no fields | | Missing `from` | 400 | | Unknown table | 401 | | A syntax error, unknown field or function, type mismatch, or `1/0` | 400. **Some messages include HTML**, such as a `javascript:` link | | Function and field name case | Ignored: `today()` and `[title]` work | | `fieldTypeApplied` | `false` in every test. Sending `fieldType` or `resultType` changed nothing, and we don't know what sets it | | **Other tables** | **A formula can read other tables** with `GetRecord()` and `GetRecords()`, whatever `from` says | | **Tables in another app** | **Not readable, and there's no error.** On 2026-11-04, with a token that could reach both apps, `GetRecord()`, `GetRecords()`, and `Size()` from another app returned 200 with an empty result, exactly as for a table ID that doesn't exist. The same formula run inside the table's own app returned the value, and so did a same-app read from the first app. A saved formula field behaved the same way. Quickbase's help describes queries as working on "unrelated tables in the same app". Only table IDs were tried, not aliases | | Other information | `AppID()`, `URLRoot()`, and `UserToEmail(User())` return the app ID, realm, and the token owner's email | | Side effects | No record changed, including `Date Modified`, after about 60 runs | | Formula length | A 1,000,000-character formula worked | **Formula fields.** `POST /fields` with `properties.formula` creates one, with `mode: "formula"`. Unlike many other mistakes, these are checked: | Behavior | Result | |---|---| | A syntax error, unknown field, or result type that doesn't match the field | 400. A failed update keeps the old formula | | Updating the formula | Values change right away, and other properties are kept | | **Updating to an empty formula** | **200, and the field's values become empty** | | Creating with an empty formula, or adding a formula to a plain field | 400, `Field must be of formula mode.` | | Renaming a field a formula uses | Quickbase updates the formula's text | | An unknown function | 400, `The function name Bogus(number) is unknown.` Live API, 2026-11-06 | | A formula using another formula field | Works | | **Deleting a field a formula uses** | **Allowed. The formula keeps its text, such as `[Hours] * 2`, and its values become empty.** Any update to that formula is then refused with 400 until it stops using the deleted field. Live API, 2026-11-06 | | Writing to a formula field | Row error | | Field types that took a formula | `text`, `numeric`, `date`, `checkbox`, `duration`, `timeofday`, `currency`, `percent`, `rating`, `url`, `email`, and `datetime`, and on 2026-11-04 `phone`, `user`, `multitext`, and `rich-text` | | Field types the API refuses a formula on | `text-multi-line` and `multiuser`, with `[formula] property is not supported`. **The UI can make a `multiuser` formula**, such as `ToUserList(User())`, which reads as a list of users | | **A timestamp formula** | **`timestamp` returns 400, `[formula] property is not supported`. `datetime` works, and the field then reports its type as `timestamp`** | | `text-multi-line` | 400, `[formula] property is not supported` | | Reading formula fields in a query | Full precision, unlike a formula run: `1/3` returns `0.3333333333333333`. A duration returns milliseconds, and a time of day `11:34:49.896` | | **A blank number in a formula** | **Can read as 0, not empty.** With a numeric field made through the API left empty, and no child records, `If(IsNull([Hours]), null, [Hours] - Nz([Hours Logged]))` returned 0, so `IsNull([Hours])` was false. This is probably the field's "treat blank as zero" setting, which wasn't checked or turned off. Live API, 2026-11-05 | ### Rate limits Tested on 2026-11-02 with short bursts from 12 threads against temporary tables. | Behavior | Result | |---|---| | `retry-after` on a real 429 | **Whole seconds**, seen from `0` to `10`. After waiting that long, the next request succeeded | | 450 calls from 12 threads, with the default 4 retries | Took about 30 seconds and hit 24 to 53 real 429s. **All 450 succeeded in most runs, but in 1 of 7, a call used up its retries and raised `RateLimitError`.** With retries off, about 300 of the 450 raised it | | The same burst with `1.0.0rc3`, which waits before retrying on a `retry-after` of 0 | 10 runs on 2026-11-03: **all 450 succeeded every time**, with 24 to 42 real 429s, in 27 to 37 seconds. That fits an improvement over 1 failure in 7 runs, but a sample this small can't prove one | | `retry-after: 0` | Sent while still limiting, so an immediate retry can get another 429 | | 429 body | `{"error": "Quota has been exceeded"}`, **not the usual `message` and `description`** | | When it started | After roughly 170 to 185 responses in 4 to 6 seconds, for both `/formula/run` and `/records/query`. That's more than the documented 100 per 10 seconds, so don't rely on the number. These bursts don't show a difference between the two endpoints | ### Temporary tokens **The SDK doesn't support temporary tokens.** Their main use is browser code pages, a script can't get one, and the SDK's table lookup needs `GET /tables`, which a table-scoped token can't call. These findings are kept for when that's revisited. Supporting them would need a `temporary_token` client option that treats `app.table()` as an ID and skips that lookup, for a backend acting for a logged-in user. Tested on 2026-11-02 against temporary tables in a sandbox app, with tokens fetched from a logged-in browser's console, because a script can't get one. | Behavior | Result | |---|---| | `GET /auth/temporary/{table or app ID}` from a script | **400, `Domain not allowed`, with or without credentials.** The check runs before authentication | | The same request from a page on the realm | Works, using the browser's session (`credentials: "include"`). Returns `temporaryAuthorization` | | Format | An opaque string of 137 characters, with letters, digits, and `.` or `=` | | Header | `Authorization: QB-TEMP-TOKEN ` | | Lifetime | The token stopped working 266 seconds after being pasted into the test. It was fetched shortly before that, so this fits the documented 5 minutes without confirming it | | Expired, or out of scope | Both return 401, `Invalid Authorization.`, so they can't be told apart | | A malformed token, or a user token with this header | 400, `Invalid Authorization` | | A temporary token with `QB-USER-TOKEN` | 401, `User token is invalid` | | Requesting another token with one | 400, `Domain not allowed` | | Request | Table-scoped token | App-scoped token | |---|---|---| | Records in its table: query, write | **200, including writes** | 401 | | Fields and reports of its table, running a report or formula | 200 | 401 | | Another table: query, write, fields | 401 | 401 | | `GET /tables` and `GET /apps/{id}` | **401** | 200 | | **A formula that reads another table** | **200, and returns that table's data** | 401 | Tokens are scoped narrowly, and an app-scoped token doesn't reach its tables' contents. **Table scope doesn't contain a formula**: `GetRecord()` read a table the token couldn't otherwise reach. Uploads, downloads, and file deletes weren't tested with a temporary token. A token from a user with fewer permissions was only tested on a delete: see [Code pages and dashboards](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#code-pages-and-dashboards). ### Code pages and dashboards Tested on 2026-11-03 in a sandbox app, in a logged-in browser, using a code page that runs Python in a sandboxed frame. Dashboard settings were checked by hand, in the app's own screens. | Behavior | Result | |---|---| | A code page's security policy | `default-src * 'unsafe-eval' 'unsafe-inline' data: filesystem: about: blob: ws: wss; worker-src 'none';`, so a page can't start a worker. Almost everything else is allowed, so any tighter limit has to come from the page itself | | A code page in a dashboard's Web page widget | **Served from `realm.ui.quickbase.com`, and `GET /auth/temporary` fails there with no CORS header.** Reloading the page from the realm's own address, inside the widget, works | | Saving a code page that contains `QB_USER_TOKEN="..."` | A warning that storing secrets in a browser page is insecure, even for a placeholder. It doesn't prevent saving, and moving the value into a variable didn't clear it | | The dashboard rich text editor | Removes inline styles and collapses line breaks in `
`. `
` and ` ` work | | KPI widgets | Built from a gauge chart report. Tile colors come from the report's Low, Medium, and High legend colors | | KPI widget settings | **Show target** hides the `TARGET` line, and **Custom title** replaces the report's name, so neither needs a change to the report | | A gauge report saved with a goal of 0 | Still displays, and its settings page marks the goal "Must be greater than start" | | Chart widget settings | Only **Report** and **Custom title**. Everything else is set on the report | | A horizontal bar chart in a narrow widget | **Its category labels disappear, while the bars and axis remain.** There's no setting for this in the widget | | Table report **Options** | **Show edit icon** and **Show view icon** remove the row icons, and **Hide row numbers** works on a table report in a dashboard, though the help only describes it for grid reports | | A narrow browser window | The dashboard doesn't rearrange its widgets. It scrolls sideways, so a Web page widget keeps its width | | Dashboard reports after a write through the API | **They don't update until the page is reloaded** | | A delete with a table-scoped temporary token, from a role without delete rights | 403, `Insufficient permissions: Not enough rights to execute this request.` | | The same delete with a filter that matches nothing, `{3.LT.0}` | Also 403, so it shows whether a role can delete before anything changes | ### Restricted users Tested on 2026-11-02 against a temporary table in a sandbox app, with a second user's token. Its role could only view that table, had no access to the app's other tables, and had access `None` on one text field and one file field. See also [roles with write access](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#roles-with-write-access) and [record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules). | Behavior | Result | |---|---| | A token's permissions | Its user's, through their role | | `GET /fields` | **A hidden field is left out**, exactly as if it didn't exist, so a deleted field and a hidden one can't be told apart | | `GET /fields/{id}` for a hidden field | 500, `QuickBase is experiencing technical difficulties` | | `GET /tables` | Lists only tables the role can use | | A table created through the API | The role gets no access to it, so `GET /tables` leaves it out until its permissions are set in the UI. Live API, 2026-11-06 | | `GET /apps/{id}` | 403, for this role | | `select` with a hidden field and a visible one | **200, and the hidden field is silently left out** | | `select` with only hidden fields | 400, `Report contains a restricted field` | | `where` or `sortBy` on a hidden field | 403, whether or not anything matches | | Running a table report with a hidden column | **200, and the column is silently left out** | | A file field's value, read with view access | Includes `creator`, with **the uploader's email address** | | Downloading from a visible file field | 200 | | Downloading from a hidden file field | 404, `No such field` | | A formula using a hidden field, directly or through `GetRecords()` | 403, including when `GetRecords()` only filters on it. A visible field works | | Insert, update, delete, upload, deleting a file version, creating a field | 403, `Insufficient permissions`, and nothing changed | | A table the role has no access to | `GET /tables` leaves it out. Querying it, `GET /tables/{id}`, `GET /fields`, and a formula with it as `from` return 403. Tested on 2026-11-04 | | **A formula reading a table the role has no access to** | **Empty, with no error.** `GetRecord()`, `GetRecords()`, and `Size()` return 200 with `""` from a table the role can view, while an admin's token gets the real value. A saved formula field shows each user their own result. Tested on 2026-11-04 with a role that had no access to the table or its fields | ### Roles with write access Tested on 2026-11-04 in a real app, with a second user's token. Its role had custom field access on some tables, and no delete rights. See also [record-level rules](https://matthew-williams-solutions.github.io/pyquickbase/api-notes/#record-level-rules). | Behavior | Result | |---|---| | Add off, and Modify on for the table | 207, with the row error `You don't have permission to add records to this table.` | | Add and Modify both off for the table | **403, `Insufficient permissions`**, for inserts and updates, so the same refusal takes a different form | | Modify off, with an upsert that matches an existing record | Row error, `You don't have permission to update records in this table.` An upsert that creates a record works | | Inserting a duplicate into a unique field | Row error, `Adding non-unique value for field with ID "8".`, and no new record, with only add rights | | A field set to View in the role | Readable and usable in `where`. Writing it gives the row error `Insufficient permissions to update data in a restricted field.` | | **A row with an allowed field and a view-only field** | **The whole row is refused**, and the allowed field doesn't change | | **A field created after a role's Custom Access was set** | **The role gets full access to it.** It could write the new field straight away, until it was set to View. Check a role after adding any field | | `where` on lookup and foreign key fields | Works | | Upsert with add and modify rights, including repeating it | Works | | `DELETE /records` with no delete rights, with `{3.LT.0}` | 403 on every table | | `POST /fields` and `POST /tables` | 403 | | `GET /apps/{id}` and `GET /apps/{id}/roles` | **403**. `GET /tables` and `GET /fields` work | `GET /apps/{id}/roles` with an administrator's token returns `id`, `name`, and `access`, including roles you made. We didn't find any endpoint for reading or changing a role's permissions: `/apps/{id}/roles/{id}` and `/apps/{id}/roles/{id}/permissions` return 404. ### Record-level rules Tested on 2026-11-04 against 6,000 records split evenly across three categories, with a second user's token. The role's View rule was `Category` is not `Gamma`, and its Modify rule was `Category` is `Alpha`. It had Add but not Delete, and a hidden numeric field. | Behavior | Result | |---|---| | Queries, counts, and reports | Only show records the View rule allows, and `totalRecords` counts only those, so nothing reveals how many others exist | | `where` matching a hidden record, by ID or value | 200 with no records, not an error | | `GetRecords()` and `GetRecord()` in a formula | Follow the rule. A count is of allowed records, and a hidden record reads as `""` | | A formula with a hidden record as `rid` | **403, while a record that doesn't exist returns 404**, so this shows whether an ID exists | | Updating a record the Modify rule doesn't allow, visible or hidden | Row error, `You don't have permission to update records in this table.` | | Updating an ID that doesn't exist | Row error, `Can not find record by ID`, **which also shows whether an ID exists** | | A request mixing allowed and refused records | 207. The allowed rows are written, and the others get row errors | | **Changing an allowed record so the rules no longer match** | **Allowed, including moving it to where the role can't see it.** The rules check the record before the change, not after | | **Inserting a record the role won't be able to see** | **Allowed.** The new ID comes back, but `fieldsToReturn` values don't, and the role can't read it afterward | | Deleting, with no delete rights | 403, including a record the role just added | | Writing to a hidden field | Row error, `Insufficient permissions to update data in a restricted field.` | **Reports using a hidden field.** A report that sorts or filters on a field the role can't see isn't available to that role, and nothing about the hidden field is revealed: | Behavior | Result | |---|---| | `GET /reports` | **Leaves the report out**, so the SDK raises `UnknownReportError`, by name or ID | | `POST /reports/{id}/run` | **400, `Invalid request: Internal error`.** A report ID that doesn't exist returns 404, so this shows that the report exists | | A report that only shows the hidden field as a column | Listed, and runs without that column | | `GET /reports/{id}` | 403 for every report we tried with this role, even ones it can run | ### Custom data rules Rules are written in the table's Advanced settings, and the API can't create them. Tested on 2026-11-04 in a real app. The help says they also cover imports, grid edit, and connected table refreshes, which weren't tested. | Behavior | Result | |---|---| | A rule refusing an update or insert through the API | 207, with the row error `Table rule validation: ` | | **An administrator** | **Is bound by the rule**, through both the API and a form | | A pipeline's write | Is bound by the rule. The run fails with `Custom data rule failed`, Error Code 88 | | Deleting a record | The help only mentions adding and editing, and this wasn't tested | | **`IsNull()` on an empty numeric field with `blankIsZero: true`** | **False, because the field counts as 0.** A rule using it refused every record until `blankIsZero` was set to false, which the API accepts | ### Pipelines Tested on 2026-11-04 in a real app, with the Quickbase channel's **Record Updated** trigger and **Update Record** action. Pipelines can't be created or read through this API. | Behavior | Result | |---|---| | An update through the API | Fires the trigger, and the action had run within 4 seconds | | A field the action reads | Must be listed in the trigger's "Fields for subsequent steps" | | The trigger's filter | `Status is Open` didn't fire on a change from Open to Draft, and did on Draft to Open | | The record changing before the action runs | The action works on the record as it is then, so it can hit a custom data rule | ### Relationships Tested on 2026-11-03 and 2026-11-04 in a real app, using `POST /tables/{child}/relationship/{id}`. | Behavior | Result | |---|---| | `lookupFieldIds` | **The parent's field IDs, and it adds to the existing lookups.** Sending an existing lookup's ID, which is on the child, looked up whichever parent field has that number, and created a field nobody asked for | | A new lookup's label | ` - `, such as `Buyer - Name` on a parent whose record name is `Client`, and it can be renamed | | A second `POST /tables/{child}/relationship` to the same parent | **Makes another relationship**, even with the same foreign key label | | `accumulationType` values | The API lists `AVG`, `SUM`, `MAX`, `MIN`, `STD-DEV`, `COUNT`, `COMBINED-TEXT`, `COMBINED-USER`, and `DISTINCT-COUNT`, in capitals | | Summaries that worked | `SUM` of numeric and currency, `AVG` and `STD-DEV` of numeric, `MAX` and `MIN` of numeric and date, `DISTINCT-COUNT` of numeric and text, `COMBINED-TEXT` of text, and `COMBINED-USER` of user. Each kept the child's type, except that `AVG`, `STD-DEV`, and `DISTINCT-COUNT` are numeric, and the combined ones are `multitext` and `multiuser` | | Summaries refused with 400 | `SUM` of checkbox, `MAX` of text, and `COUNT` of numeric, text, checkbox, and the record ID | | **`COUNT` takes no field** | With no `summaryFid`, or `summaryFid: 0`, it counts the child records, as a `numeric` summary with `summaryTargetFieldId: 0`. With any field, including the record ID, it's 400, `That form of summarization/aggregation cannot be applied to this field`. Lowercase `count` is 400. Live API, 2026-11-05 | | **A `COUNT` with no child records** | **0, not empty**, unlike the other summaries. Live API, 2026-11-05 | | A lookup's properties | `lookupReferenceFieldId` is the foreign key on the child, and `lookupTargetFieldId` is the field on the parent | | `summaryFields`, with `summaryFid`, `label`, and `accumulationType: "SUM"` | Creates the field on the parent. Works on a formula field of the child | | A summary's properties | `summaryReferenceFieldId`, `summaryTargetFieldId`, and `summaryFunction` | | **A summary with no child records** | **Empty, not 0**, except a `COUNT`. A formula using it is also empty unless it uses `Nz()`, which matters for something like a balance | | Fields added to the parent | A `dblink` such as `Task records`, and a `url` formula such as `Add Task`, containing `a=API_GenAddRecordForm` | | `DELETE /tables/{child}/relationship/{id}` | 200, `{"relationshipId": 7}`. **Deletes the lookups on the child, and the summaries and the link and add-record fields on the parent. The foreign key field stays.** A second delete is 404, `Parent table not found. Delete the field 'Parent' to remove this relationship.` Live API, 2026-11-06 | | `GET /tables/{parent}/relationships` | **Empty for the parent.** Only the child lists a relationship. Live API, 2026-11-06 | | `where` on a `summaryFields` item | **Filters the child records the summary combines**, in a create or an update of the relationship, in Quickbase's query language. With child records of 10 and 7 matching and 5 not, a filtered `SUM` read 17 and an unfiltered one 22, and a filtered `COUNT` read 2. Live API, 2026-11-06 | | A summary's filter, read back | `properties.summaryQuery` from `GET /fields` and `GET /fields/{id}`, exactly as sent. **It's left out for a summary with no filter.** The relationship's response doesn't include it. Live API, 2026-11-06 | | **A summary filter Quickbase can't use** | **Saved with 200 and no error**, both a malformed one (`{nonsense`) and one on a field that doesn't exist (`{999.EX.true}`). The summary then reads as empty. Live API, 2026-11-06 | | **Summaries and the reader's role** | **They only total the child records the reader can see.** With 4 child records and a custom rule letting a restricted role see 2, an admin read a count of 4 and a total of 40, while the restricted user read 2 and 20. With View set to None on the child table, the restricted user read both as empty, even the `COUNT`. Live API, 2026-11-06 | ### Field types Tested on 2026-11-02 against temporary tables in a sandbox app. These decide the types in generated classes, and an unlisted type becomes `Any` and read-only. `POST /fields` accepts these types: `text`, `text-multiple-choice`, `text-multi-line`, `rich-text`, `numeric`, `currency`, `rating`, `percent`, `multitext`, `email`, `url`, `duration`, `date`, `datetime`, `timestamp`, `timeofday`, `checkbox`, `user`, `multiuser`, `address`, `phone`, and `file`. A `datetime` field reports its type as `timestamp`. | Field type | A query returns | When empty | |---|---|---| | `recordid` | Integer | Never empty | | `text`, `text-multiple-choice`, `text-multi-line`, `rich-text`, `email`, `url`, `phone` | Text | **`""`, not null** | | `numeric`, `currency`, `rating`, `percent` | Number | null | | `duration` | Milliseconds | null | | `date` | `2026-03-04` | `""` | | `timestamp` | `2026-03-04T05:06:07Z` | `""` | | `timeofday` | `13:30:00` | `""` | | `checkbox` | Boolean | `false` | | `multitext` | A list of text | `[]` | | `user` | `{"email", "id", "name"}` | null | | `multiuser` | A list of users | `[]` | | `file` | `{"url", "versions"}` | `{"url": "", "versions": []}` | | `address` | Display text, such as `1 Main St, Springfield 12345` | `""` | | `dblink` | `""`, including when the relationship has child records | `""` | | `vCardButton`, `ICalendarButton` | A relative link, such as `
?a=VCard&fid=14&rid=b`, on every record, even with no data to show | The link, or `""` as a lookup with no parent | | `predecessor` | Record IDs as text, such as `1` or `1;2` | `""` | | Behavior | Result | |---|---| | Formula fields | Return the same shapes as their type. A formula of `Null` matched its type on text, numeric, and checkbox fields | | Lookup fields | Return exactly what the parent field returns, filled and empty, for every type `POST /fields` accepts except `file`, and for numeric, text, and checkbox formulas. With no parent record, they read as that type's empty value. Tested on 2026-11-04 | | A lookup's reported type | The parent's type, except that a lookup of `text-multiple-choice` **reports `text`** | | A lookup of a `file`, `dblink`, or `predecessor` field | 400, `The following field ids can not be lookup fields` | | A lookup of a type only the UI can create | `vCardButton`, `ICalendarButton`, and a `multiuser` formula return what the parent returns, and report the parent's type. Tested on 2026-11-04 | | Types only the UI can create | vCard, iCalendar, and Predecessor, which report as `vCardButton`, `ICalendarButton`, and `predecessor`. A Report link reports as `dblink`, and a relationship also creates one | | Writing to `vCardButton`, `ICalendarButton`, or `dblink` | Row error, `Can not change the value of the read-only field` | | Writing to `predecessor` | Works. It takes a record ID, as text or a number, or several in a list or a separated string, stored as written: `1,2` reads as `1, 2`. A missing ID or other text gives `Invalid entry in field`, and a record linking to itself gives a row error about links back to the first record. `""` clears it. `where` with `EX` matched a record holding that ID | | Writable fields | Built-in fields 1 to 5 aren't, nor are formula, lookup, and summary fields, or `address` | | An `address` field | Is read-only, with text subfields for its parts, which can be written. Creating one also creates its subfields, and sets `Country` to a default | | `dblink` | Created by a relationship, on the parent table | | Labels | **Can contain almost anything, including `"""`, line breaks, and code**, as can table names | | Label uniqueness | Exact duplicates are refused, ignoring case, but `Due Date` and `Due-Date` can both exist | | **Spaces in a label** | **Kept exactly.** `" Spaced "`, `"Title "`, and `"Two Spaces"` are stored as sent, by creating and renaming, and `Title`, `" Title "`, and `"Title "` can all exist on one table. Live API, 2026-11-06 | | Table names | `POST /tables` and renaming with `POST /tables/{id}` refuse a name already in use with 400, `Table name exists`, ignoring case and surrounding spaces | | Changing a table with `POST /tables/{id}` | Needs `appId`, or it's 400, `Required query 'appId' not found`. An empty body is 200 and changes nothing. Live API, 2026-11-06 | | Table names in the UI | Creating a table with a name already in the app, or renaming a table to one, is refused too. Tried by hand with the same name on 2026-11-04. Case and spacing variations weren't tried in the UI | | Writing a timestamp | `2027-01-02T03:04:05+00:00` and three fractional digits are accepted. **Python's default of six, or one, is a row error**, `Incompatible value`. It reads back to the second | | `properties` | An unknown key is refused with 400, `Extraneous key`. `fieldHelp` isn't a property, and neither are `choices` on a type without them | | Settings outside `properties` | `POST /fields` and `POST /fields/{id}` take `fieldHelp`, `bold`, `noWrap`, `findEnabled`, and `appearsByDefault` beside `label`, and `GET /fields` returns them. `addToForms` is accepted on create and never returned. **`audited: true` was accepted and read back as false**, in an app without audit logging set up. `fieldHelp` inside `properties` is 400, `Extraneous key 'properties.fieldHelp' is not permitted`. Live API, 2026-11-05 | | A field's response | Also lists `permissions`, each role's name, ID, and access to the field. `GET /fields` leaves them out | | **Writing a `duration`** | **Milliseconds**, as it reads: `90000` reads back as `90000`, and `3600000` is an hour. Negative and 0 work. **A fraction is dropped with no error**, so `1.5` reads back as `1`. Text is parsed too: `"90000"`, `"1:30:00"`, and `"2 hours"` became `90000`, `5400000`, and `7200000`. Live API, 2026-11-05 | | `POST /fields/{id}` with `required: true` | 400, `field contains null values`, if any record has it empty, which includes a new field on a table with records | | `POST /fields/{id}` with `unique: true` | 400, `field contains non-unique values`, if it holds duplicates. A new, empty field on a table with records is allowed | | A `phone` value | Reformatted: `5551234567` reads back as `(555) 123-4567` | | A built-in field in `DELETE /fields` | Refused in `errors`, and the others in the list are deleted | | Field IDs | Assigned in order, and address subfields use up IDs | | `GET /tables/{id}` | Returned an error without its `appId`, so use `GET /tables?appId=` | ### Apps, field usage, and events Tested on 2026-11-08 against the Integration Tests app and temporary apps the test made and deleted. | Behavior | Result | |---|---| | `POST /apps` with `assignToken: true` | 200, and the same token can read the new app at once | | `POST /apps/{id}/copy` | 200 with the copy's details and `ancestorId`. `keepData: true` copies the records, and without it the tables are empty | | `DELETE /apps/{id}` with `{"name": ...}` | 200, `{"deletedAppId": ...}` | | Listing apps | No endpoint, so an app left behind can only be found in the interface | | `GET /fields/usage` on a table of 10 fields | **Every field, built-in ones included, in one response**, as `{"field": {"id", "name", "type"}, "usage": {kind: {"count": n}}}`. `type` is lower case, such as `text` | | Kinds of use | `actions`, `appHomePages`, `customEmails`, `dashboards`, `defaultReports`, `documentTemplates`, `exactForms`, `fields`, `forms`, `notifications`, `personalReports`, `pipelines`, `relationships`, `reminders`, `reports`, `roles`, `tableImports`, `tableRules`, `webhooks`. `customEmails` and `documentTemplates` aren't in the published spec | | A new text field's usage | `reports: 2` and `defaultReports: 1` at once, and `forms: 1` a few seconds later, so **counts lag a change by a few seconds** | | `GET /fields/usage` with `top` and `skip` | Pages as expected, but **a `skip` past the last field returns 500** | | `GET /fields/usage/{id}` | A list with one entry | | `GET /fields/usage` and `GET /apps/{id}/events` with a restricted user's token | **403**, `Insufficient permissions`, so both need an app administrator | | `GET /apps/{id}` and `GET /tables/{id}/relationships` with a token whose role lacks "Edit app structure and permissions" | **403**, `Insufficient permissions`, on an app the user can open. `GET /tables`, `GET /fields`, and `GET /reports` still return 200 for the tables the role can see. Tested on 2026-11-08 | | `GET /apps/{id}/events` on an app with none | 200, `[]` | ### Values written as text Tested on 2026-11-08 on a temporary table. | Value | Result | |---|---| | A `user` field set to an email address, in any case | Sets that user | | A `user` field set to a user ID, or `{"id": email}` | Sets that user | | **A `user` field set to `{"email": email}`** | **200, and the field is unchanged** | | A `multiuser` field set to a list of emails, or one email as text | Sets those users | | A `percent` field set to `"0.45"` | Reads as `0.45` | | **A `percent` field set to `"1,234.5"` or `"45%"`** | **200, and the field is emptied** | ### Interface links These are the current Quickbase interface's addresses, which the SDK's `*_url()` methods build. The interface isn't part of the API, so Quickbase may change them without notice. | Page | Path, after `https://` | Source | |---|---|---| | App home | `/nav/app//action/appoverview` | Browser, 2026-11-05 | | App settings | `/nav/app//action/appsettingshome` | Browser, 2026-11-05 | | Table home | `/nav/app//table/
/action/td` | Browser, 2026-11-05 | | Report | `/nav/app//table/
/action/q?qid=` | Browser, 2026-11-05 | | Show a record | `/nav/app//table/
/action/dr?rid=` | Browser, 2026-11-05 | | Edit a record | `/nav/app//table/
/action/er?rid=` | Browser, 2026-11-05 | | Edit a field's settings | `/nav/app//table/
/action/mf?fid=` | Browser, 2026-11-05 | | Edit a report | `/nav/app//table/
/action/viewBuild?qid=` | Browser, 2026-11-05 | | Add a record | `/nav/app//table/
/action/nwr` | Browser, 2026-11-05 | | A table's fields | `/nav/app//table/
/action/listfields` | Browser, 2026-11-05 | | A table's relationships | `/nav/app//table/
/action/Relationships` | Browser, 2026-11-05 | | A table's settings | `/nav/app//table/
/action/KeyProps` | Browser, 2026-11-05 | | Edit a form | `/nav/app//table/
/action/DFormProps?dfid=` | Browser, 2026-11-05 | The interface's own links add `skip=0` to a report, `rl=...&page=1` to a record, `chain=1` to a field, `originalQid=...` to a new record or a report's settings, and `page=1` to a new record. These look like the interface's state, so the SDK leaves them out. Without `originalQid` and `page`, the app settings, report settings, new record, field list, relationships, table settings, and form links each opened the right page (browser, 2026-11-05). The API can't list forms, so a form's ID comes from its address in the interface. A link to the records that match a filter isn't built, because no form has been confirmed. The classic interface took `/db/
?a=q&query=`, and it isn't known whether that still works. ## Changelog Source: All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### [Unreleased] #### Fixed - `Policy.bind()` no longer fails for a role without "Edit app structure and permissions", which Quickbase doesn't show relationships to. If the policy hides a field or limits rows, every lookup and summary field is then hidden too, because what they read can't be traced. ### [1.0.0rc13] - 2026-11-08 This candidate adds CSV and Excel files, DataFrames, more statistics in summaries, field usage, app events, and creating, copying, and deleting apps. **It removes the `pyquickbase codegen` command and typed tables,** and a file generated by an earlier release no longer imports, as listed below. #### Added - **CSV and Excel files.** `to_csv()` and `to_excel()` on `query()`, a report's `run()`, and any `Page` write records as they're fetched, with a header row. `Table.rows_from_file()` reads a CSV, tab-separated, or Excel file into a `FileRows`, converting each value by its field's type, such as `1,234.5`, `45%`, `yes`, or a user's email, and listing every value it can't convert with its row and column. `insert()`, `update()`, `upsert()`, and `preview_update()` take it, and refuse it, writing nothing, while it has errors. CSV text that a spreadsheet would run as a formula is written with a leading `'`, and Excel text is always text. Excel needs `pyquickbase[excel]`. - **`rows_from_file(max_rows=...)` and `max_unzipped_bytes=...`** limit what a file may hold, for a file you didn't make. Reading stops at `max_rows`, and an Excel file that would unpack to more than `max_unzipped_bytes`, 1 GB by default, isn't opened. Both raise `FileTooLargeError`, an `InvalidValueError`, with `limit` and `unit`. - **The async client works with files on a worker thread.** `rows_from_file()`, `to_csv()`, and `to_excel()` on a query or report no longer stop the event loop while they parse or save a file. - **More statistics in `summarize()`:** `median`, `std_dev` (sample standard deviation), and `distinct_count`, beside `sum`, `avg`, `min`, and `max`. Each is exact, worked out from `Decimal` values, and the `summarize` operation offers them to models, with aliases such as `stdev`. - **`summarize(compare_periods=True)`** gives each row a `change`, a `PeriodChange` with how its count and statistics changed since the period before, within the same other groups. A period without records counts as empty rather than being skipped. - `SummaryRow.statistic()` reads a statistic by name, and `StatisticName` names them. - **DataFrames.** `to_pandas()` and `to_polars()` on `query()`, a report's `run()`, and any `Page` fetch records into a DataFrame, with a column for each field, which a query with no matches still has. `insert()`, `update()`, `upsert()`, and `preview_update()` take a pandas or polars DataFrame as their rows, where a missing value leaves a field empty. Install `pyquickbase[pandas]` or `pyquickbase[polars]`. Neither library is loaded unless you use it. - **`Table.field_usage()`** says where fields are used, such as in reports, forms, and pipelines, as a `FieldUsage` for each, from the counts on each field's Usage page. A plan that deletes a field lists these uses in its warnings. - **`App.events()` and `Table.events()`** list webhooks, Quickbase actions, notifications, subscriptions, reminders, and record-triggered automations, as `AppEvent`s. Both previews of writes list the active ones on the table in `events`, since changing records may set them off, and `tool_result()` includes them, with a warning, in a preview operation's result. - **`Quickbase.create_app()`, `App.copy()`, and `App.drop()`** create, copy, and delete apps. `drop()` needs the app's exact name. A client with a policy refuses all three, as a policy covers one app. - `Page.columns`, the labels of the fields a page returned, in order. - **`tool_result(max_chars=...)`** limits how many characters a result takes as compact JSON, so one call can't fill a model's context. Records and summary groups are included until the next wouldn't fit, and `page.has_more` and `page.next_skip` say where to carry on, so paging still reaches every one. At least one is always included, with its text cut shorter if it's too large on its own. - **A `summarize` operation sorts and pages its groups.** `order_by` sorts them by `count`, by a `group` field, or by a `sum`, `avg`, `min`, or `max`, with empty values last, and `limit` and `skip` page through them. A question such as "which five clients have the most projects" no longer returns every client. The summary is still of every matching record. - **`operation("query", max_default_fields=...)`** returns only the first of the table's default fields when the model doesn't name any in `select`, and `tool_result()` adds `fields_left_out` with how many it left out and a hint to name them. - `TooManyRecordsError`, with `matched` and `limit`, and the tool error code `too_many_records`. #### Changed - **`anyio` 4.1 is now the lowest supported version,** as worker threads need it under Trio. - **Writes refuse two values Quickbase loses without an error:** text that isn't a plain number for a number field, such as `"1,234.5"`, which Quickbase leaves empty, and a user written as `{"email": ...}`, which it ignores. Both raise `InvalidValueError`. Plain numbers as text, such as `"0.45"`, still work. - Every statistic is defined once, in `_summary.STATISTICS`, which the summary, the operation's schema, group sorting, and `tool_result()` read, so adding one changes one place. - **`preview_delete()` returns a `DeletePreview`,** a `Page` with `events`. - `ReadAction` adds `field_usage` and `app_events`, and `WriteAction` adds `create_app`, `copy_app`, and `delete_app`. A custom rule that tested for `other` on these requests no longer sees them. - **Summary groups have a `page` in `tool_result()`,** with `returned`, `total`, `skip`, `next_skip`, and `has_more`, as records do. A `summarize` operation returns its page of groups, which reads as a list of `SummaryRow`, as before. - **`summarize()` reads in large pages.** It used to read 1,000 records per request. Now `page_size` defaults to `None`, which sizes each page by the fields the summary reads, from 25,000 records for one field down to 1,000, so a page takes about 25 MB of memory. Totalling 250,000 records takes about 11 requests instead of 250. Passing a `page_size` still uses it. - **A summary that only counts reads no records.** With no `group_by`, `sum`, `avg`, `min`, or `max`, `summarize()` asks Quickbase how many records match, in one request, at any size. It used to read every matching record, with the table's default fields. `max_records_scanned`, the argument and the read rule's, no longer limits it, as it reads nothing. - **The API reference is reorganized.** Every page has each entry's code beside it and its contents in the left sidebar, and entries you call are titled by what they do. The menu groups the pages, and the overview lists every public name. "Limits and retries" is now three pages: `Policy` and `BoundPolicy` are under [Policies](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/), the rules under [Rules](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/), and `RetryPolicy` under [Retries](https://matthew-williams-solutions.github.io/pyquickbase/reference/retries/), so links to `reference/limits/` need updating. - **A summary over `max_records_scanned` raises `TooManyRecordsError`,** and `tool_error()` gives it the code `too_many_records` with `matched` and `limit`. It was a `QueryError` with the code `invalid_arguments`. `TooManyRecordsError` is a `QueryError`, so `except QueryError` still catches it, but a tool that checks for `invalid_arguments` needs to check for `too_many_records` too. #### Removed - **The `pyquickbase codegen` command,** and the `pyquickbase` console script that ran it. Use `app.table()` with field labels, or keep classes you've already generated as your own code. - **Typed tables:** `App.typed()`, `TypedTable`, `TypedIterator`, and their async versions, with `TypedRecord`, `TypedField`, `TypedPage`, `Column`, `Row`, `Select`, `OrderBy`, `Unset`, and `UNSET`. `tool_result()` no longer takes a `TypedRecord` or `TypedPage`. A file generated by an earlier release no longer imports. #### Fixed - **`Policy.bind()` is typed for the async client.** Binding with an `AsyncApp` returned `Any`, so a type checker didn't see the `BoundPolicy` after `await`. It now returns a coroutine of one, and `app` is typed `App | AsyncApp`. ### [1.0.0rc12] - 2026-11-07 This candidate replaces the permission classes with one `Policy`, and adds schema changes that are planned before they're made, for scripts and for agents. **It breaks every client that used `read_only`, `read_policy`, or `write_policy`,** with no compatibility layer, as listed below. #### Added - **`Policy`, one argument for what a client may do.** `AsyncQuickbase(policy=...)` and `Quickbase(policy=...)` take a policy of `allow`, `deny`, and `limit` rules over tables and fields, by name and label, or a `fields` or `tables` selector. Anything no rule allows is refused, and a deny always wins. `Policy.bind(app)` looks up every name once, reports every problem in one `ConfigurationError`, and returns a frozen `BoundPolicy` keyed by ID. - **One evaluator.** `BoundPolicy.decide()` is the only code that decides a request, and returns a `Decision` with the layer, the rule as written, a reason, and a hint, naming tables and fields by name and ID. `allows()` and `explain()` answer questions about it, and `offers()` asks it about each field and verb, which is what operations and schema operations offer, so a model is never offered what's refused. - **Classes** name groups of fields, and carry over to every formula, lookup, and summary that reads one, through chains and across tables, including the fields a summary's filter uses. A formula that can't be read counts as reading its whole table. - **`new_fields`** says what a field added after binding gets: `read`, `hidden`, or a class. It's never writable until the policy is bound again. - **`limit.rows()`** ANDs a condition onto every query, summary, and delete the client sends, including into a new summary field's filter. It hides existing summaries that read past it, and lookups from a row-limited table, and refuses reports, downloads, and formulas there. - **Schema verbs granted where a change lands:** `lookup` on the child, with `read` on the parent's field, `summary` on the parent, with `read` on the child's field and `filter` on its filter's fields, and `link` and `unlink` on both tables. Creating a table gives the client a new `BoundPolicy`, as `client.policy`, with the table in its `created` list for `tables.created()`. - **Saving a bound policy** with `to_json()` and `BoundPolicy.from_json()`, and a `hash` of it. - **Rules of your own**, as objects with `decide(request)`, which can only refuse. - **`PlannedChange.policy_effects`**, which says who could read and write a new field, its classes, and which rules lose a deleted field or table. A rule a deletion leaves matching nothing is a blocker, and so is a label that a field the client can't see already uses. - **Summaries of some child records.** `ensure_summary_field()`, `ensure_count_field()`, and `CreateSummaryField` take `where`, which is checked against the child's fields before it's sent, as Quickbase saves a filter it can't use and the summary then reads as empty. Under a policy, the filter may only use fields the client can filter on, and it's shown, and compared with an existing summary's, when every field it uses is readable on the child. `Field.summary_query` holds a summary's filter, and a plan warns when a deleted field is one a summary filters on. - **`App.preview_schema_changes()` and `App.apply_schema_changes()`.** Changes such as `CreateField`, `UpdateField`, `DeleteField`, `CreateTable`, `CreateRelationship`, `CreateLookupField`, and `CreateSummaryField` are checked against the app as it is. Each `PlannedChange` says what it would set, its `risk`, what else it would do or affect, such as records that lose a deleted field's values or formulas that use it, and anything that blocks it, including the client's policy. Applying plans again first, and refuses if the touched tables and fields have changed. A failure raises `SchemaChangeError`, which says what was made, and planning again shows only what's left. - **Schema operations for models:** `App.operation()` offers `describe_schema`, `preview_schema_changes`, and `apply_schema_changes`, which only runs with the confirmation its plan returned, for the same changes, while the schema is as it was. Each schema offers only what the client's policy allows. A change names what it does in `action`, such as `create_lookup_field`, a relationship's tables in `child_table` and `parent_table`, and a summary's `function` in lower case, such as `sum` or `std_dev`. `kind`, `child`, `parent`, `create_lookup`, and Quickbase's own function names are read too. Each planned change is `ready`, `already_done`, or `blocked`, and a plan's result has the same top-level `changes` and approval keys as an update preview. - **Checks on what a new formula, lookup, or summary reads.** Under a policy, one may only use fields the client can read, and a formula may only call functions known to stay on the record. A formula can't make a link or rich text. - **`Table.update_info()`** renames a table or changes its descriptions, and **`Table.delete_relationship()`** unlinks a child from its parent. - **`confirmation_key`** on the client, so several processes accept each other's previews. - **`TableSchema`**, and `tool_result()` for plans, results, and table schemas. - **`ConfirmationError`**, with the tool error code `needs_confirmation`, for a change without the confirmation its preview or plan returned, or with one that's expired, for other arguments, from another client, or from a client with a different policy. - **Operations and `where_from_dict()` read the spellings models tend to write** for a closed value, as the one value each means: `starts with` or `startsWith` for `starts_with`, `eq`, `=`, `ne`, `<>`, `lt`, `gte` and the like for the symbols, `and` and `or` for `all` and `any`, `sort` for `order_by`, and an `order_by` item as a bare label, a single item, or with `order`, `dir`, or `descending`. Labels must still match exactly. A preview and its change share a confirmation whichever spelling each used. - **`F.has()` and `F.not_has()`**, for one exact value of a multi-select or list-user field. #### Changed - **A `partial_write` whose `data_changed` is `unknown` has its own `hint`:** check what was written, then send only what's missing, as the batch that failed may have been written. - **`Layer` no longer lists `confirmation`,** which no check made. A missing or mismatched confirmation raises `ConfirmationError`, not `PolicyRefusedError`. - **A failed schema change has its own `hint`** in `tool_error()`: preview the same changes again, which shows only what's left, as it has no failed rows to fix. - **Each limit is named for what it limits.** `Table.operation()` takes `max_records_per_call` and `max_records_per_write` for `max_records` and `max_rows`. `summarize()` takes `max_records_scanned`. `allow.read()` takes `max_paging_depth` and `max_records_scanned` for `max_records` and `max_summary_records`, and `allow.delete()` takes `max_records_per_delete`, as do the fields of `Offers`. `tool_result()` takes `max_text_chars` for `max_text`, and a delete preview's `most` is `max_deletable`. - **Descriptions say what they describe.** `Table.operation()` and `where_schema()` take `field_descriptions`, and `Table.operation()` takes `report_descriptions={ID: text}`, with `reports` holding only IDs. - **`create_field()` and `ensure_field()` take `type`**, not `field_type`, as `Field.type` and the schema changes do, and so does a generated class's `Column`. - **`create_relationship()` and `ensure_relationship()` take `foreign_key`**, not `foreign_key_label`. - **`FileUpload` and `FileDownload` hold `file_name`**, not `name`, as `FileVersion` and the JSON file value do, and a downloaded file in `tool_result()` has `file_name`. - **`record_url_pattern()` is `record_url_template()`**, with `{record_id}` where the ID goes. - **Paging has one word per meaning.** `limit` is the most records one call returns, everywhere: it replaces `top` in `query_page()`, `preview_delete()`, `Report.run_page()`, their typed versions, and `ReadRequest`. `max_total` replaces `limit` on `query()`, `Report.run()`, and `summary_rows()`, where it stops iterating across pages. - **Writes take `records`**, not `rows`, in `insert()`, `update()`, `upsert()`, and `preview_update()`, as the operations do. - **Aggregates are `sum`, `avg`, `min`, and `max`** in `summarize()`, on `SummaryRow`, in the `summarize` operation's arguments, and in `tool_result()`'s groups, as summary fields are. The operation still reads `total`, `average`, `minimum`, and `maximum`. - **Results and errors say what they hold.** A `count` operation's result is `{"count": n}` and a `delete` operation's is `{"deleted_count": n}`, rather than `value`. An error's `changed` is `data_changed`, with `no`, `partly`, or `unknown` for `none`, `some`, or `unknown`. A shortened text value sets `text_truncated` rather than `text_shortened`, and a failed row's `kind` is `reason`. - **Error codes:** `invalid_query` is `invalid_arguments`, as it covers every operation's arguments, `write_permission` is `write_permission_denied`, `configuration` is `misconfigured`, `network` is `network_error`, and `error` is `quickbase_error`. - **`RowError.kind` is `RowError.reason`.** - **The `summary_report` operation is `run_summary_report`**, to match `run_report`. - **`ensure_summary_field()` takes `function` in lower case**, such as `sum` or `std_dev`, as schema changes do. Quickbase's names, such as `SUM`, still work, and an unknown one raises `InvalidValueError` before anything is sent. - **`describe_schema` names each relationship's parent table** in `parent_table`, beside its ID. - **`order_by` items take `"direction": "asc"` or `"desc"`**, defaulting to `asc`, in place of `"descending": true`, which is still read. - **The filter operators `HAS` and `XHAS` are `has` and `not_has`** in `where_from_dict()` and the schemas. The capitals are still read. - **Every refusal is a `PolicyRefusedError`**, holding its `Decision`, with a message of the form `Refused by : . . (rule: ) Nothing was sent. To allow it: .` A hidden field still raises `UnknownFieldError`. `tool_error()` keeps the codes `read_refused` and `write_refused`, chosen by the verb refused, and drops `refused`. - **A confirmation starts with an ID for its key**, and is bound to the hash of the client's policy. - **`ConfirmationError` is no longer a `QueryError`.** - **A write whose field keys can't be read is refused**, even where every field may be written. - **`WriteRequest.action` names each schema change**, such as `create_field`, rather than `other`, and `WriteRequest` has `app_id`, `parent_table_id`, `field_type`, and `derived`. - **Previews share their approval keys:** `blockers`, `warnings`, `confirmation`, and `expires_at`. A delete preview over its limit lists a blocker, rather than `over_limit`. #### Removed - **`read_only`, `read_policy`, and `write_policy`.** Pass `policy=Policy.read_only()` for `read_only=True`, and a bound `Policy` for the others. - **`ReadRules`, `ReadAccess`, `WriteRules`, `WriteAccess`, `SchemaAccess`, and `all_of()`,** with their `new_tables` arguments, and the `ReadPolicy` and `WritePolicy` types. A policy function becomes a `Rule` of your own, which can only refuse. - **`WriteRefusedError`, `ReadRefusedError`, and `RequestRefusedError`,** for `PolicyRefusedError`. ### [1.0.0rc11] - 2026-11-05 This candidate adds links to the Quickbase interface, so that a tool built on the SDK can show a person where to check what a model read or changed. From testing an MCP server in Claude Desktop, an operation can now require its preview, and describe its scope to the model. Nothing that worked before has changed. #### Added - **Links to the Quickbase interface**, so a tool can show a person where to check what a model read or changed. `App.url()`, `Table.url()`, and `Report.url()` link to an app, table, or report, `Table.record_url()` to a record's page for viewing or editing it, and `Table.field_url()` to the page that edits a field. `Table.record_url_pattern()` gives one link with `{Record ID#}` where each record's ID goes, so a tool needn't send a link per record. Names are looked up through the client's cache, nothing else is sent to Quickbase, and anything the client's `ReadRules` hide is refused as unknown. `App.url()` is a plain method in both clients, as it needs no lookup. - **Links to the schema pages a model's changes send a person to:** `App.settings_url()`, `Report.url(edit=True)` for the report's settings, and on `Table`, `new_record_url()`, `fields_url()`, `relationships_url()`, `settings_url()`, and `form_url(form_id)`, which takes the form's ID because the API can't list forms. - **`require_preview=True` on the `update` and `delete` operations.** In testing, a model updated a record with no preview, though told to preview first. With this, they only run with the `confirmation` their preview returned for exactly the same arguments, within 15 minutes, and a delete is refused if how many records match has changed since. `tool_result()` includes the confirmation. Confirmations are signed with a key each client makes, so nothing is stored. - **`scope=` on `table.operation()`**, as well as on `run()`. In testing, a model that couldn't see a scope tried to set its field, was refused, and then told the person something untrue about why. Given to `operation()`, the scope is described in the schema, such as `Billable is true`, its fields are left out of a write's schema, a write that sets one is refused with a message saying why, and an `insert` scope that couldn't work is refused when the operation is made. - **`Table.compile_where()`** returns a condition, from `F`, `where_from_dict()`, or a string, as the query text that a query of the table would send. ### [1.0.0rc10] - 2026-11-05 This candidate helps a model get its arguments right, from testing an MCP server built on the SDK in Claude Desktop: report operations take a report the way models send one, schemas say what a percent means, and refusals for a label or choice in the wrong case give the right one. Every call that worked before still works, and only some refusal messages read differently. #### Fixed - **The `run_report` and `summary_report` operations refused a report sent any way but its ID as text**, so a model that sent `8` rather than `"8"`, or the report's name, was told it couldn't run a report its tool listed. `run()` now also takes the ID as a number, or the report's exact name, ignoring case, of a report the tool lists. A name shared by two of them is refused, asking for the ID. The schema still lists the IDs, and its description says a name works too. - **Nothing told a model that a percent is a fraction**, so asked for "25% done" it could write `25`, which Quickbase stores as 2,500%, or filter on `50`, which matches nothing. An operation's schema now says so for a percent field it writes, and `where_schema()`, given `Field` objects, says what a number means on a percent or duration field it offers. - **A field label or choice in the wrong case gets a refusal that names the right one**, such as `select[0] isn't a field this tool may use there. Labels must match exactly, as in 'Title'.` It only names a field or choice the tool offers, and only when exactly one matches. - **A report of the other kind gets a refusal that says what to do.** A table report sent to `summary_report`, or a chart sent to `run_report`, says which operation runs it, where the message only said it couldn't be run. ### [1.0.0rc9] - 2026-11-05 This candidate fixes filters on percent and duration fields, which matched the wrong records with no error, and helps a model's tools say why a delete or write would be refused before anyone approves it. Each was found by testing an MCP server built on the SDK, and checked against the live API. **Upgrading.** A percent or duration filter that worked around the bug by sending Quickbase's own units as a number, such as `F("Done") >= 50` for 50%, now means 5,000%. Send `0.5`, or the text `"50"`, which is still sent as written. #### Fixed - **Filters on `percent` and `duration` fields compared the wrong values, and matched the wrong records with no error.** A query compares a percent in whole percent, so `F("Done") >= 0.5` matched 0.5% and up. A bare number on a duration matches nonsense, so `F("Budget") > 28800000` matched nothing. `F` and `where_from_dict()` now send a percent as whole percent and a duration as `N milliseconds`, which compares exactly, so a number means what it means when writing: a fraction, and milliseconds. Text is still sent as written. The same applies to a `scope` and to `delete()`. #### Added - `F` takes a `timedelta` on a `duration` field. - A `preview_delete` operation's result says, in `tool_result()`'s `over_limit`, when more records match than its `max_records`, so a model knows the delete would be refused before asking anyone to approve it. - A write that sets a field the tool may read but not write now names the field. A field it can't read still isn't named. #### Changed - The refusal for a read past `ReadAccess(max_records=...)` says that it limits how far paging can go, not only the size of a page, and so do the docs for `ReadAccess` and `table.operation()`. ### [1.0.0rc8] - 2026-11-05 This candidate holds the fixes from a review before 1.0. Most important, it closes gaps in what a model's tools could do: an operation's `scope` limited what a write filtered but not what it wrote, and the `delete` operation had no limit. It also reads user and file values as dataclasses, adds `select` to typed tables, retries reads after more kinds of failure, and fills in what the API was missing, such as `COUNT` summaries, duration writes, and field settings. Each was checked against the live API. **Upgrading.** These changes can break working code: - **User and file values are dataclasses, not dictionaries.** Read `owner.email` rather than `owner["email"]`, and `attachment.latest.file_name` rather than `attachment["versions"][-1]["fileName"]`. An empty file field is None, where it was `{"url": "", "versions": []}`. - **A `duration` field takes whole milliseconds or a `timedelta`.** A fraction, such as `1.5`, or text, such as `"1:30:00"`, raises `InvalidValueError`. - **An operation's write can't set a field its scope uses**, and `insert` needs a scope of `==` conditions joined with `&`. - **httpx 0.28 or later is required.** #### Security - An operation's `scope` now holds for what a write sets, not only for what it filters. Before, a model with an `insert` tool scoped to one project could create records in another, and an `update` tool could move a record out of its scope. `insert`, `update`, and `preview_update` now refuse a row that sets a field the scope uses, and `insert` fills those fields in from the scope. It refuses to run when the scope isn't only `==` conditions joined with `&`, as a new record couldn't be made to match it. - The `delete` operation counts what its filter matches first, and refuses the call, deleting nothing, when that's more than `max_records`, 100 by default. Before, one call could delete every record in its scope. The tool's schema tells the model the limit. #### Added Typed tables and values: - `User`, `FileAttachment`, and `FileVersion`, the frozen dataclasses that user, `multiuser`, and file fields read as, in records and generated classes. A `User`'s `repr` leaves out the email address, and a `User` can be written to a user field or compared in `F`. - `select` on a typed table's `query()`, `query_page()`, `first()`, and `get()`, which reads only the fields of the class you list. The other attributes are left unset, so reading one raises `AttributeError` saying it wasn't selected. `Select` is its type. - Generated classes type `vCardButton` and `ICalendarButton` fields as read-only `str`, and `predecessor` fields as a writable `str`, rather than `Any`. Run `pyquickbase codegen` again to get them. Writing: - `WriteResult.row_ids`, which gives each row of an `insert()` its new Record ID#, in the order of your rows, with None for a row that failed. `created_ids` leaves failed rows out, so it only lines up with your rows when none failed. - Operations can write `duration` fields, as whole milliseconds, and `predecessor` fields, as text. Table writes and generated classes also take a `timedelta` for a duration. - `timeout` on `insert()`, `update()`, `upsert()`, and `download_file()`, and on a typed table's writes, which replaces the client's timeout for that call, such as for a large file. It applies with your own `http_client` too. Schema: - `ensure_count_field()`, which counts a child table's records on the parent, and counts 0 for a parent with none. `COUNT` takes no field, so `ensure_summary_field()` now raises `InvalidValueError` for it, where Quickbase refused every field it was sent. - `help_text`, `bold`, `no_wrap`, `find_enabled`, and `appears_by_default` on `create_field()`, `update_field()`, and `ensure_field()`, and `add_to_forms` on `create_field()`, for the settings Quickbase takes beside `properties`. `Field.help_text` reads a field's help, and `ReadRules` keep it. #### Changed - **Breaking:** user and file values are no longer dictionaries, as described under Upgrading. `tool_result()` writes a user as `{"id", "name", "email"}`, and a file version as `{"number", "file_name", "uploaded", "creator"}`. - **Breaking:** a `duration` field refuses fractions and text, because Quickbase drops a fraction of a millisecond without saying so, and guesses at text. - **Breaking:** pyquickbase needs httpx 0.28 or later, whose request bodies it measures to keep each batch under Quickbase's 40 MiB limit. httpx 0.27 sent larger bodies than were measured. - Reads, meaning GET requests, record queries, report runs, and formula runs, are retried after a timeout, a dropped connection, or a 502, 503, or 504, as sending one again changes nothing. A write still isn't. Any request is also retried after httpx's `PoolTimeout`, as nothing was sent. - The `preview_update` operation offers only fields the client may both read and change, so it no longer offers a write-only field that every call would be refused for. #### Fixed - `summarize()` on a client with `ReadRules` raises `ReadRefusedError` if more records match while it reads than `max_summary_records`, rather than returning a summary of only some of them. - `tool_error()` reports `changed: "some"` when a failed call still changed something: when `delete_fields()` deleted some of the fields, and when `create_field()` created the field but couldn't set `required` or `unique`. That error's message now says the field was created. - `insert()`, `update()`, and `upsert()` raise `InvalidValueError` for a NaN or infinite number, rather than a `ValueError` from httpx. - `Decimal` values, in writes and queries, and `float` values in queries, are written without an exponent, so `Decimal("1E+3")` is sent as `1000`. A NaN, infinite, or enormous number in a query raises `QueryError`. - `preview_update()` reads in batches no larger than the client's `ReadRules` `max_records`, so a preview of more records than that is no longer refused. - `UnknownReportError` says the report may be hidden from the token's role, as Quickbase hides one whose filter or sort uses a field the role can't see. Running such a report by ID through `qb.request()` gives `Internal error`, and that error now says why. #### Documentation - The guides to errors, files, and formulas are rewritten by task, and the codegen and clients guides gain complete first examples. Each ends with a table of what each refusal raises and leaves behind. - The playground simulates updates, deletes, and queries on user fields, with two new examples, and each guide section links to the example that shows it. - The API notes record how Quickbase writes durations, counts child records, and takes field settings. ### [1.0.0rc7] - 2026-11-05 This candidate adds limits on what a client can read, output for a model's tool call, and table operations offered to a model with their JSON Schema. It also makes the API reference a set of pages. It has breaking changes: the write limits are renamed and `allowed_writes` is removed, so check the list under Changed before upgrading. #### Added - `read_policy` on `Quickbase` and `AsyncQuickbase`, which decides on each read before it's sent, and works with any of the limits on writes. `ReadRules` and `ReadAccess` list what a client may read from each table: which fields it may return, filter on, and sort by, which reports and file fields it may use, and how many records one query can reach. A table or field they don't list acts as if the token couldn't see it. They also leave email addresses out of user and file values unless asked, and keep only the metadata the SDK reads, so a field's formula and a report's filter aren't in `raw`. They refuse formulas, app details and roles, reports run through `request()`, and any change to tables or fields. A policy can also be your own function, which takes a `ReadRequest`. - `ReadRefusedError`, raised for a read the policy refuses, and `RequestRefusedError`, the base of it and of `WriteRefusedError`. - `tool_result()` and `tool_error()`, which turn results and errors into plain data for the result of a model's tool call, for any tool-calling framework. Results use the same shapes and value formats every time: dates as `YYYY-MM-DD`, timestamps in UTC, every empty value as null, and paging as `returned`, `total`, `skip`, `next_skip`, and `has_more`. `compact=True` sends each label once, and `max_records` and `max_text` cut a result while saying so. Errors carry a fixed `code` from `ToolErrorCode`, a `hint` for the model, whether a retry can help, and whether anything was `changed`, which tells a partial write from a complete failure. An exception from outside the SDK says nothing about itself. - `table.operation()`, which turns one table operation into a tool for a model: an `Operation` with the JSON Schema of the arguments a model may send, and `run()`, which checks them against the same lists before calling the table. It covers queries, counts, summaries, inserts, updates, deletes, their previews, table and summary reports, formulas, and file downloads and deletes. The schema offers only what the client's `ReadRules` and `WriteRules` allow, and an operation the client can't run is refused when it's made. `run()` turns JSON values into dates, times, numbers, and file uploads by field type, and `scope` adds a condition your code fixes to every call. A formula's text is always fixed by your code. `descriptions` tells the model what fields mean. - `tool_result()` takes table, field, and report metadata, without `raw`, and a downloaded file, with its text when it's UTF-8 text. - `where_schema()` takes `descriptions`, and `where_from_dict()` takes `Field` objects in `fields`, so the same list works for both. #### Changed - **Breaking:** renamed `TableRules` to `WriteRules`, `Allow` to `WriteAccess`, and `ReadOnlyError` to `WriteRefusedError`, so that the names for limiting writes match those for limiting reads. A refused write still raises the same error in the same places. - **Breaking:** removed `allowed_writes`, which only did what a `write_policy` can. A policy that checks `request.method` and `request.path` does the same, as [Combining limits](https://matthew-williams-solutions.github.io/pyquickbase/guides/clients/#combining-limits) shows. - `all_of()` combines read policies as well as write policies, but not the two kinds at once. - A field or table that can't be found now says "this client can't see it" rather than "this token", as a read policy can hide it too. - The message of `AuthenticationError` now says that a 401 may mean a wrong ID rather than a bad token. Quickbase uses the same wording for a bad token, an app or table that doesn't exist, and one the token can't reach, so the error's type stays the same. #### Documentation - The API reference is now a set of pages, one for each class or topic, with a description of every argument and attribute and an example for both clients, which live tests run. Each page has a plain Markdown copy, listed in `llms.txt`. - Links to anchors in the old reference page, such as `reference/#pyquickbase.Table`, no longer work. - The reference page "Write limits and retries" is now "Limits and retries", and covers read limits. ### [1.0.0rc6] - 2026-11-05 This candidate adds what an AI agent or MCP server needs to use Quickbase safely: filters from plain data, limits on what a client can write, previews of writes, and summaries. It has one breaking change, to `insert()`, so check the list under Changed before upgrading. #### Added - `where_from_dict()`, which builds a query condition from plain data, such as the JSON from an AI model's tool call or a web form. It refuses anything it doesn't recognize, and checks each value against its field's type, so `today` or `garbage` on a date field raises `QueryError`. Its `fields` argument limits which fields a filter may use. - `where_schema()`, which describes that format as JSON Schema with no `$ref`, for a tool's input in MCP or any other LLM tool system. - `write_policy` on `Quickbase` and `AsyncQuickbase`, which decides on each write before it's sent. `TableRules` and `Allow` list what a client may do to each table, by action and by field, and refuse everything else. A policy can also be your own function, which takes a `WriteRequest`, and `all_of()` requires several to pass. Only a return value of `True` lets a write through. - `preview_delete()` and `preview_update()` on tables and typed tables, which show what a delete or update would do without changing anything, so that a person or model can check it first. `preview_update()` returns the new `UpdatePreview`, with each change's before and after values. - `summarize()` on tables and typed tables, which counts records and finds totals, averages, minimums, and maximums, overall or per group, with `Period` to group dates by month and so on. It reads every matching record, and `max_records` refuses a scan larger than you expect, rather than returning a partial result. - `Report.summary_rows()`, which reads a summary or chart report built in the UI as `{column label: value}` rows. These reports used to need `qb.request()`. #### Changed - **Breaking:** `insert()` now raises `InvalidValueError`, and sends nothing, on a table whose key field isn't `Record ID#`. Before, a row with an existing value in that field silently changed that record rather than creating one, and `created_ids` could end up shorter than the rows. Use `upsert()` on those tables. Changing the key field in the UI can take as long as `schema_ttl` to be noticed. ### [1.0.0rc5] - 2026-11-04 This candidate closes gaps that would be hard to change after 1.0, and has four more breaking changes, so check the list under Changed before upgrading. #### Added - `Expression`, `FieldRef`, `FieldRefs`, and `OrderBy` are now exported, as they appear in public signatures. - `batch_size` on `insert()`, `update()`, and `upsert()` of a typed table. - `TypedTable.query_page()`, which returns a page of generated class instances as a new `TypedPage`, with `total`, `skip`, `has_more`, and `next_skip`. It runs the same schema check as other typed methods. #### Fixed - A `datetime` with a fraction of a second can now be written, including `datetime.now(UTC)`. Before, Quickbase refused it as a row error, because Python's default of six fractional digits isn't accepted. - `order_by` and `select` on an untyped table now accept a field from a generated class or `F`, such as `tasks.table.query_page(order_by=Tasks.fields.title)`. Before, it raised a bare `TypeError`. Any other unusable value now raises `QueryError`. - `ensure_table()` now returns a table you can use when the name differs only in case from an existing one. Before, it found the table, then raised `UnknownTableError` on first use. #### Changed - **Breaking:** `==` and `!=` on a `user` field now work, by email or user ID. Before, `==` silently matched nothing, and `!=` silently matched every record, including that user's, so a `delete()` with it could remove more than intended. Check any code that compares a `user` field. - **Breaking:** `F` now raises `QueryError` for `_curuser_` on a `user` or `multiuser` field, because Quickbase reads it as the token's owner, so a value from someone else could match that person's records. A query string can still use it on purpose. - **Breaking:** a typed table now raises `InvalidValueError` for a row made by a class for another table, whose field IDs would mean different fields. Before, only a type checker refused it. - **Breaking:** `order_by` on a typed table now takes only what its signature says, a field of the generated class or its ID, alone or in a list. Anything else raises `QueryError`. Before, `["Title"]` and `[F("Title")]` happened to work, `"Title"` was read as one field name per letter, and `F("Title")` raised a bare `TypeError`. Use `Tasks.fields.title`. - Fuller docstrings for the schema, query, and write methods, longer schema, reading, and writing guides, and a new field types page. - `SchemaMismatchError`'s description now covers the `ensure_` methods, which also raise it. ### [1.0.0rc4] - 2026-11-04 These changes come from building a customer portal on the SDK. #### Changed - **Breaking:** generated classes now read `currency` fields as `Decimal`, and foreign keys and `numeric` fields set to 0 decimal places as `int`. A fractional value in an `int` field raises `InvalidResponseError` rather than being rounded, because Quickbase only uses that setting for display. Generate your classes again, and update code that expects `float` for these fields. - **Breaking:** `pyquickbase codegen` now leaves out the links Quickbase makes for each relationship, such as `Add Task` and `Task records`. Pass `--include-links` to keep them. - **Breaking:** a write refused by the token's role now always raises `WritePermissionError`, which is both a `WriteError` and a `PermissionDeniedError`. Before, the same refusal raised one or the other depending on the role's other permissions. #### Added - `RowError.kind` and `RowError.field_id`, so you can recognize a duplicate value, a refused row, or a row refused by a custom data rule, without reading Quickbase's wording. - `App.ensure_table()`, `Table.ensure_field()`, `Table.ensure_relationship()`, `Table.ensure_lookup_field()`, and `Table.ensure_summary_field()`, for setup scripts that are safe to run again. The last two also add lookups to an existing relationship and create summary fields, which needed `request()` before. - `first()` and `get()` on `Table` and `TypedTable`. - `App.roles()`, and the new `Role` type. - `Field` attributes for what a lookup or summary points at, plus `decimal_places` and `is_foreign_key`. `Relationship.summary_field_ids`. - `from_env(token_env=...)` and `pyquickbase codegen --token-env`, for a program with several tokens. ### [1.0.0rc3] - 2026-11-02 #### Added - A guide to [using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). - [`llms.txt`](https://matthew-williams-solutions.github.io/pyquickbase/llms.txt) and [`llms-full.txt`](https://matthew-williams-solutions.github.io/pyquickbase/llms-full.txt) on the docs site, which hold a list of the docs and all of them in one file. - The playground's sample app now has three linked tables, with users, tags, attachments, a lookup, a formula, and rollups, and four more examples. #### Fixed - `pyquickbase codegen` now writes the same line endings on every system, so generating on Windows doesn't change every line of a file generated elsewhere. - A `Retry-After` of 0 now waits using `RetryPolicy`'s backoff instead of retrying immediately. Quickbase can send it while still limiting, so immediate retries could be used up during a burst. - If a later batch of a write got a successful response in the wrong shape, `InvalidResponseError` lost the results of the earlier batches. It's now a `WriteError`, like any other failure in a later batch. ### [1.0.0rc2] - 2026-11-02 #### Security - `F` no longer lets a value act as query syntax, which could match, or delete, records it shouldn't. `_FID_6` compares with field 6, so `F("Title") == "_FID_6"` matched every record, and it now raises `QueryError`. `a OR b` matched `a` and `b` rather than that text, and now matches that text. #### Fixed - `insert()` and `upsert()` now raise `InvalidValueError` for a row with no values, because Quickbase can silently skip it, which also shifts `created_ids` against your rows. - `pyquickbase codegen` now always reads and writes UTF-8, so labels like `日本語` no longer fail or produce a broken file on Windows. - `table.relationships()` documents that it lists only relationships where the table is the child. - A corrupt compressed response, or a redirect loop with your own `http_client`, now raises `NetworkError` instead of an `httpx` exception. - Reading pages now raises `InvalidResponseError` if a response ignores `skip`, instead of returning the same records forever. #### Added - A [playground](https://matthew-williams-solutions.github.io/pyquickbase/playground/) on the docs site, which runs the SDK in the browser against simulated data, with nothing to install. ### [1.0.0rc1] - 2026-11-02 #### Added - A stability and versioning policy, which applies from 1.0. #### Changed - `TypedTable.check_schema()` now always fetches the table's fields again, instead of doing nothing after the first check. Other methods still check once, on first use, against cached metadata. - A generated class no longer fails when a field becomes writable. Otherwise, supporting a new field type in a later version would break classes generated earlier. - Generated names no longer depend on the Python version running `pyquickbase codegen`, so a label can't get a different name on a newer Python. - The error from `TypedRecord.from_record()` now mentions that a report may have left out a field. - `SchemaMismatchError` and `UnknownFieldError` now say a field may be hidden from the token, because Quickbase leaves out fields a role can't see, as if they didn't exist. #### Removed - `from_api()` on `AppInfo`, `TableInfo`, `Field`, `Relationship`, `ReportInfo`, `FormulaResult`, and `Page`, `FileDownload.from_response()`, `FileUpload.to_api()`, and `WriteResult.merged()` and `summary()`. These were internal helpers, and they're now private so they can change freely. ### [0.2.0a1] - 2026-11-02 #### Added - A documentation site. - Reports: `table.reports()`, `table.report()`, and `Report` with `run()`, `run_page()`, and `info()`, plus `ReportInfo`, `UnknownReportError`, and `UnsupportedReportError`. Only table reports can be read as records. - File attachments: `FileUpload` for writes, plus `table.download_file()` and `table.delete_file_version()`. - `pyquickbase codegen`, which generates a typed class for each table, and `app.typed()`, which reads and writes through those classes, including `query()`, `insert()`, `update()`, and `upsert()`. A class that no longer matches its table raises `SchemaMismatchError`. - Formulas: `table.run_formula()`, which returns a `FormulaResult`, `formula=` on `create_field()` and `update_field()`, and `Field.formula`. #### Changed - `read_only` and `allowed_writes` clients now allow running formulas, with `POST /formula/run`. - A blank formula now raises `InvalidValueError` on `create_field()` and `update_field()`, including through `properties`, because Quickbase accepts it on a formula field and empties the field's values. - A file attachment field now accepts only a `FileUpload`, and raises `InvalidValueError` for anything else, including `None` and hand-built dictionaries, because Quickbase silently accepts those and keeps or loses the file. - Writes are also split by size, to stay under Quickbase's 40 MiB request limit, so `batch_size` is now an upper limit. - `read_only` and `allowed_writes` clients now allow running reports, with `POST /reports/{id}/run`. This adds to the report's usage count in Quickbase, but changes no records. - `Record.id` now explains what's missing when a report's records have no Record ID#. It still raises `KeyError`. #### Fixed - A rate limit error now includes Quickbase's message, which arrives in a different format. - A README example that was missing an import. ### [0.1.0a1] - 2026-11-02 First alpha release. #### Added - `Quickbase` and `AsyncQuickbase` clients with `request()` for calling any endpoint. - `RetryPolicy`, with retries for HTTP 429 and connection failures. - The `QuickbaseError` exception hierarchy. - `qb.app()` and `app.table()`, with app, table, field, and relationship metadata and a schema cache. - `F` for building queries by field label, with values escaped for you. - Checks that reject broken `where` strings that Quickbase would otherwise accept. - `table.query()` for automatic paging, plus `query_page()` and `count()`. - `Record`, with access by label or ID and date and timestamp conversion. - `read_only=True` on the clients, which refuses any request that could change data. - `allowed_writes` on the clients, which limits them to reads plus the writes you list. - `parse_where()` for inspecting a hand-written `where` string. - `table.insert()`, `update()`, `upsert()`, and `delete()`, with per-row error reporting in `WriteError`. - `app.create_table()`, `table.create_field()`, `update_field()`, `delete_fields()`, `create_relationship()`, and `drop()`. ## API reference: Quickbase Source: [`Quickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) is the client that everything else starts from. Use it as a context manager, so that its connections get closed. [`AsyncQuickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) takes the same options, except that `http_client` is an `httpx.AsyncClient`, and has the same methods, which you `await` where the examples show it. The examples below use `qb`, made as shown here. A live test runs every example with both clients and checks its output. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: print(qb.realm) ``` ### What the methods return | Type | What it is | Returned by | |---|---|---| | [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) | A Quickbase app. Create one with `qb.app(app_id)`. This makes no network calls. | [`app()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#app), [`create_app()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#create_app) | | [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) | A `Policy` resolved against one app, keyed by ID, which decides every request a client sends. It's frozen: a client that creates a table gets a new one, as `client.policy`. | [`policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#policy) | | [`Quickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) | A Quickbase API client. Use it as a context manager so connections get closed. | [`from_env()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#from_env) | ### Create and close a client For the full picture, see [Configuring clients](https://matthew-williams-solutions.github.io/pyquickbase/guides/clients/). #### `Quickbase()` A Quickbase API client. Use it as a context manager so connections get closed. ```python Quickbase( *, realm: str, token: str, base_url: str = "https://api.quickbase.com/v1", retry: RetryPolicy | None = None, timeout: float = 30.0, user_agent: str | None = None, http_client: httpx.Client | None = None, schema_ttl: float = 300.0, policy: Policy | BoundPolicy | None = None, confirmation_key: bytes | None = None, ) ``` **Parameters** - **`realm`** `str`, required Your realm, as `acme`, `acme.quickbase.com`, or a realm URL. - **`token`** `str`, required A Quickbase user token. - **`base_url`** `str`, default `'https://api.quickbase.com/v1'` The API root. Must use HTTPS, except for loopback addresses. - **`retry`** `RetryPolicy | None`, optional How to retry rate limits, connection failures, and reads that time out or get a 502, 503, or 504. A write isn't retried after anything that may have reached Quickbase. - **`timeout`** `float`, default `30.0` Seconds to allow for each step of a request, such as connecting or waiting for the response, as httpx applies it. Ignored if you pass `http_client`. - **`user_agent`** `str | None`, optional Identifies your application, placed before the SDK's own User-Agent. - **`http_client`** `httpx.Client | None`, optional Your own configured httpx client, for proxies, custom TLS, event hooks, or custom transports. You remain responsible for closing it. - **`schema_ttl`** `float`, default `300.0` Seconds to cache app, table, and field metadata. Use 0 to disable caching. - **`policy`** `Policy | BoundPolicy | None`, optional What the client may do, as a [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) from `Policy.bind()`, or a [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy) that names no table or field, such as `Policy.read_only()`. Every request is checked before it's sent, including through [`request()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#request), and a refused one raises [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError). What the policy hides is left out of every response, so a hidden field acts as if it didn't exist. Without one, the client does whatever the token can. - **`confirmation_key`** `bytes | None`, optional The secret that signs the confirmations an operation's preview returns, at least 32 random bytes, such as from `secrets.token_bytes(32)`. Give several clients, or processes, the same key so that any of them accepts a preview another made. Without it, each client makes its own. **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): `realm`, `token`, or `base_url` can't be used, or `policy` names tables or fields but isn't bound. Example: ```python from pyquickbase import Quickbase, Policy with Quickbase( realm="acme", token="your_user_token", policy=Policy.read_only(), ) as qb: print(qb.realm) ``` Output: ```text acme.quickbase.com ``` #### `Quickbase.from_env()` Create a client from the `QB_REALM` and `QB_USER_TOKEN` environment variables. It takes the same options as the constructor, other than `realm` and `token`. ```python from_env( *, base_url: str = "https://api.quickbase.com/v1", retry: RetryPolicy | None = None, timeout: float = 30.0, user_agent: str | None = None, http_client: httpx.Client | None = None, schema_ttl: float = 300.0, policy: Policy | BoundPolicy | None = None, confirmation_key: bytes | None = None, token_env: str = "QB_USER_TOKEN", ) -> Quickbase ``` **Parameters** - **`base_url`** `str`, default `'https://api.quickbase.com/v1'` The API root. Must use HTTPS, except for loopback addresses. - **`retry`** `RetryPolicy | None`, optional How to retry rate limits, connection failures, and reads that time out or get a 502, 503, or 504. A write isn't retried after anything that may have reached Quickbase. - **`timeout`** `float`, default `30.0` Seconds to allow for each step of a request, such as connecting or waiting for the response, as httpx applies it. Ignored if you pass `http_client`. - **`user_agent`** `str | None`, optional Identifies your application, placed before the SDK's own User-Agent. - **`http_client`** `httpx.Client | None`, optional Your own configured httpx client, for proxies, custom TLS, event hooks, or custom transports. You remain responsible for closing it. - **`schema_ttl`** `float`, default `300.0` Seconds to cache app, table, and field metadata. Use 0 to disable caching. - **`policy`** `Policy | BoundPolicy | None`, optional What the client may do, as a [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) from `Policy.bind()`, or a [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy) that names no table or field, such as `Policy.read_only()`. Every request is checked before it's sent, including through [`request()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#request), and a refused one raises [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError). What the policy hides is left out of every response, so a hidden field acts as if it didn't exist. Without one, the client does whatever the token can. - **`confirmation_key`** `bytes | None`, optional The secret that signs the confirmations an operation's preview returns, at least 32 random bytes, such as from `secrets.token_bytes(32)`. Give several clients, or processes, the same key so that any of them accepts a preview another made. Without it, each client makes its own. - **`token_env`** `str`, default `'QB_USER_TOKEN'` The name of the variable that holds the token, for a program that has several. **Returns** [`Quickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) A new client. **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): A variable is missing or empty, or an option can't be used. Example: ```python from pyquickbase import Quickbase, Policy with Quickbase.from_env( policy=Policy.read_only() ) as qb: app = qb.app("bq1234567") print(app.id) ``` Output: ```text bq1234567 ``` #### `Quickbase.close()` Close connections. Safe to call more than once. The async client calls this `aclose()`. ```python close() -> None ``` Example: ```python from pyquickbase import Quickbase client = Quickbase(realm="acme", token="your_user_token") try: print(client.realm) finally: client.close() ``` Output: ```text acme.quickbase.com ``` ### Use a client For the full picture, see [Configuring clients](https://matthew-williams-solutions.github.io/pyquickbase/guides/clients/). #### `Quickbase.app()` Get an app by ID, the `bq...` part of its URL. This makes no network calls. ```python app(app_id: str) -> App ``` **Parameters** - **`app_id`** `str`, required The app's ID, which contains only letters and numbers. **Returns** [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) An [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/). An ID that doesn't exist only fails on first use. **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): `app_id` contains anything but letters and numbers. Example: ```python app = qb.app("bq1234567") print(app.id) ``` Output: ```text bq1234567 ``` #### `Quickbase.realm` The full realm hostname, such as `acme.quickbase.com`. ```python realm: str ``` Example: ```python print(qb.realm) ``` Output: ```text acme.quickbase.com ``` #### `Quickbase.policy` The client's current policy. Creating a table replaces it with one that knows the table, so save this one with `to_json()` to use it elsewhere. ```python policy: BoundPolicy | None ``` Example: ```python from pyquickbase import Quickbase, Policy read_only = Policy.read_only() with Quickbase.from_env(policy=read_only) as qb: print(qb.policy.created) ``` Output: ```text () ``` #### `Quickbase.clear_schema_cache()` Forget cached metadata, for example after changing a table's fields. Use this after a change made outside this client, such as in the Quickbase interface. The client's own schema methods keep the cache current. ```python clear_schema_cache() -> None ``` Example: ```python qb.clear_schema_cache() ``` #### `Quickbase.request()` Call any Quickbase endpoint directly and return its decoded JSON body. This is the escape hatch for endpoints without a dedicated method. The client's retries and its policy still apply. ```python request( method: str, path: str, *, params: Mapping[str, Any] | None = None, json: Any = None, ) -> Any ``` **Parameters** - **`method`** `str`, required The HTTP method, such as `GET`. - **`path`** `str`, required The endpoint, relative to the API root, such as `/tables/abc123`. - **`params`** `Mapping[str, Any] | None`, optional Values to send in the query string. - **`json`** `Any`, optional The value to send as the JSON body. **Returns** `Any` The decoded JSON body, or None if the response is empty. **Raises** - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client's policy doesn't allow this request. It wasn't sent. - [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError): Quickbase returned an error. This is a subclass such as [`NotFoundError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NotFoundError) or [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError), for the status it returned. Example: ```python body = qb.request("GET", "/apps/bq1234567") print(body["id"]) ``` Output: ```text bq1234567 ``` ### Create an app For the full picture, see [Working with apps](https://matthew-williams-solutions.github.io/pyquickbase/guides/apps/). #### `Quickbase.create_app()` Create an empty app and return it. Use `app.copy()` to start from another app. ```python create_app( name: str, *, description: str | None = None, assign_token: bool = True, ) -> App ``` **Parameters** - **`name`** `str`, required The app's name. Quickbase allows several apps with the same name. - **`description`** `str | None`, optional The app's description. - **`assign_token`** `bool`, default `True` Let this client's token reach the app, which a token limited to some apps can't otherwise do. **Returns** [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) An [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) for the new app. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `name` is empty. Nothing was sent. - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token's user can't create apps in this realm. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client has a policy, which covers one app. Nothing was sent. Example: ```python app = qb.create_app( "Field Jobs", description="Crews, sites, and tasks" ) print((app.info()).name) ``` Output: ```text Field Jobs ``` ## API reference: App Source: An [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) lists an app's details and gives you its tables. Get one from [`Quickbase.app()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/#app). Getting one doesn't contact Quickbase, so an ID that doesn't exist only fails on first use. The examples below use `app`, made as shown here, in an app that has a table named `Job Tasks` with three records. A live test runs every example with both clients and checks its output. [`AsyncApp`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) has the same methods, which you `await`, but `table()`, `url()`, and `settings_url()` are called without it. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: app = qb.app("bq1234567") ``` ### What the methods return | Type | What it is | Returned by | |---|---|---| | [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) | A Quickbase app. Create one with `qb.app(app_id)`. This makes no network calls. | [`copy()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#copy) | | [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) | Something in an app that runs when records change or on a schedule, such as a webhook. | [`events()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#events) | | [`AppInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppInfo) | An app's metadata. | [`info()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#info) | | [`Role`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Role) | A role in an app. | [`roles()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#roles) | | [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult) | What applying a plan did. | [`apply_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#apply_schema_changes) | | [`SchemaOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/) | One schema operation, for a model's tool call. Create one with `app.operation()`. | [`operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#operation) | | [`SchemaPlan`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaPlan) | What a list of changes would do to an app, checked against it as it is now. | [`preview_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#preview_schema_changes) | | [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) | A table in an app. Create one with `app.table("Tasks")`. | [`table()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#table), [`ensure_table()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#ensure_table), [`create_table()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#create_table) | | [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) | A table's metadata. | [`tables()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#tables) | ### Look up the app's details For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `App.id` The app's ID, as passed to `qb.app()`. ```python id: str ``` Example: ```python print(app.id) ``` Output: ```text bq1234567 ``` #### `App.info()` Get the app's metadata, from the client's cache if it's there. ```python info() -> AppInfo ``` **Returns** [`AppInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppInfo) An [`AppInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppInfo), with the app's `id`, `name`, `description`, `time_zone`, and `date_format`. **Raises** - [`AuthenticationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#AuthenticationError): Quickbase doesn't recognize the app ID. It answers with a 401 here, rather than a 404. - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token's role can't edit the app's structure. Example: ```python info = app.info() print(info.id) ``` Output: ```text bq1234567 ``` #### `App.tables()` List the app's tables, from the cache unless `refresh` is set. ```python tables(*, refresh: bool = False) -> list[TableInfo] ``` **Parameters** - **`refresh`** `bool`, default `False` Ask Quickbase instead of using the client's cache, which keeps metadata for five minutes by default. A change made through this client updates the cache, so this is for changes made elsewhere, such as in the Quickbase interface. **Returns** `list[` [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) `]` A [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) for each table the token can see. **Raises** - [`AuthenticationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#AuthenticationError): Quickbase doesn't recognize the app ID. It answers with a 401 here, rather than a 404. Example: ```python tables = app.tables() print(sorted(t.name for t in tables if "Job" in t.name)) ``` Output: ```text ['Job Projects', 'Job Tasks'] ``` #### `App.roles()` List the app's roles, from the cache unless `refresh` is set. Quickbase has no API for reading or changing what a role can do. ```python roles(*, refresh: bool = False) -> list[Role] ``` **Parameters** - **`refresh`** `bool`, default `False` Ask Quickbase instead of using the client's cache, which keeps metadata for five minutes by default. A change made through this client updates the cache, so this is for changes made elsewhere, such as in the Quickbase interface. **Returns** `list[` [`Role`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Role) `]` A [`Role`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Role) for each role in the app. **Raises** - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token isn't an administrator's. Example: ```python roles = app.roles() print(any(role.name == "Administrator" for role in roles)) ``` Output: ```text True ``` #### `App.events()` List the app's webhooks, Quickbase actions, notifications, subscriptions, reminders, and the automations that records trigger. Quickbase leaves out scheduled automations, and doesn't say which changes set each one off. With a policy, only events on tables it shows are listed, without their owners. ```python events() -> list[AppEvent] ``` **Returns** `list[` [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) `]` An [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) for each, active or not. **Raises** - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token isn't an administrator's. Example: ```python events = app.events() print([event.type for event in events if event.active]) ``` Output: ```text [] ``` ### Get a table For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `App.table()` Get a table in this app by name or ID. It's checked when first used. This doesn't contact Quickbase, so a table that doesn't exist raises [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError) on first use. ```python table(name_or_id: str) -> Table ``` **Parameters** - **`name_or_id`** `str`, required The table's name, or its ID. **Returns** [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) A [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/). Example: ```python tasks = app.table("Job Tasks") print(tasks.count()) ``` Output: ```text 3 ``` ### Link to the Quickbase interface For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `App.url()` Link to the app's home page in Quickbase. This makes no network calls. ```python url() -> str ``` **Returns** `str` The URL, on the client's realm. Example: ```python print(app.url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/action/appoverview ``` #### `App.settings_url()` Link to the app's settings in Quickbase. This makes no network calls. ```python settings_url() -> str ``` **Returns** `str` The URL, on the client's realm. Example: ```python print(app.settings_url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/action/appsettingshome ``` ### Create tables For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `App.ensure_table()` Return the table with this name, creating it if it doesn't exist. This makes a setup script safe to run again. An existing table isn't changed. ```python ensure_table( name: str, *, description: str | None = None, single_record_name: str | None = None, plural_record_name: str | None = None, ) -> Table ``` **Parameters** - **`name`** `str`, required The table's name, ignoring case when looking for an existing table. - **`description`** `str | None`, optional The new table's description. - **`single_record_name`** `str | None`, optional What the new table calls one record, such as `Project`. - **`plural_record_name`** `str | None`, optional What the new table calls several records, such as `Projects`. **Returns** [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) A [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) for the existing or new table. **Raises** - [`AuthenticationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#AuthenticationError): Quickbase doesn't recognize the app ID, or the token can't reach it. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase refused to create the table. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client's policy doesn't allow creating a table. Nothing was created. Example: ```python sites = app.ensure_table("Job Sites") again = app.ensure_table("Job Sites") first_info = sites.info() again_info = again.info() print(first_info.id == again_info.id) ``` Output: ```text True ``` #### `App.create_table()` Create a table and return it. ```python create_table( name: str, *, description: str | None = None, single_record_name: str | None = None, plural_record_name: str | None = None, ) -> Table ``` **Parameters** - **`name`** `str`, required The table's name. - **`description`** `str | None`, optional The table's description. - **`single_record_name`** `str | None`, optional What the table calls one record, such as `Project`. - **`plural_record_name`** `str | None`, optional What the table calls several records, such as `Projects`. **Returns** [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) A [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) for the new table. **Raises** - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): The app already has a table with this name. Example: ```python sites = app.create_table( "Job Sites", description="Where each job takes place.", single_record_name="Site", plural_record_name="Sites", ) info = sites.info() print(info.name) ``` Output: ```text Job Sites ``` ### Change the schema with a plan For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `App.preview_schema_changes()` Work out what schema changes would do, without making them. Each change is compared with the app as it is now, in order, so a later one can use a table or field an earlier one makes. The plan says what each would set, what else it would do or affect, such as records that lose a deleted field's values, and anything that stops it, including the client's own limits. A change the app already has counts as done, so planning again after a failed apply shows only what's left. This only reads. It may count records, and look for duplicates before making a field unique, reading up to 10,000 records. ```python preview_schema_changes( changes: Sequence[SchemaChange], ) -> SchemaPlan ``` **Parameters** - **`changes`** `Sequence[SchemaChange]`, required The changes, such as `CreateField(table="Tasks", label="Due", type="date")`. **Returns** [`SchemaPlan`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaPlan) A [`SchemaPlan`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaPlan), for [`apply_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#apply_schema_changes). **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `changes` is empty. Nothing was read. Example: ```python from pyquickbase import CreateField change = CreateField( table="Job Tasks", label="Crew", type="text" ) plan = app.preview_schema_changes([change]) for planned in plan.changes: print(planned.status, planned.summary) ``` Output: ```text ready Add field "Crew" (text) to Job Tasks. ``` #### `App.apply_schema_changes()` Make the changes in a plan, in order, if the app hasn't changed since it was made. The changes are planned again first, and nothing is sent unless that finds the same schema and nothing stopping them. Quickbase has no way to undo a change, so if one fails, the ones before it stay made, and the ones after it aren't attempted. ```python apply_schema_changes( plan: SchemaPlan, ) -> SchemaChangeResult ``` **Parameters** - **`plan`** `SchemaPlan`, required From [`preview_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#preview_schema_changes). **Returns** [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult) A [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult), with what was made and what was already there. **Raises** - [`SchemaMismatchError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaMismatchError): The schema has changed since the plan was made. Nothing was sent. - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): The plan has blockers, or is for another app. Nothing was sent. - [`SchemaChangeError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaChangeError): A change failed. Its `result` says which were made. Example: ```python from pyquickbase import CreateField change = CreateField( table="Job Tasks", label="Crew", type="text" ) plan = app.preview_schema_changes([change]) result = app.apply_schema_changes(plan) print(len(result.applied)) again = app.preview_schema_changes([change]) print(again.changes[0].status) ``` Output: ```text 1 already_done ``` ### Give a model the schema For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `App.operation()` Make a schema operation into a tool for a model, with its JSON Schema. `describe_schema` lists the tables and fields the client can read. `preview_schema_changes` says what a list of changes would do, without making them, and `apply_schema_changes` makes them, but only with the `confirmation` that `preview_schema_changes` returned for exactly those changes, within 15 minutes, while the schema is as it was then. Show the person each plan before they approve it. The schema offers only the kinds of change, tables, and field types the client's policy allows, and a plan reports anything it would refuse. Give the model a client with a policy, so that what it can change and see is limited, and a formula, lookup, or summary it adds can only show what it could already read. ```python plan = app.operation( "preview_schema_changes" ) tool = { "name": "plan_changes", "input_schema": plan.schema, } result = plan.run(arguments) ``` ```python operation( name: SchemaOperationName, *, max_changes: int = 20 ) -> SchemaOperation ``` **Parameters** - **`name`** `SchemaOperationName`, required `describe_schema`, `preview_schema_changes`, or `apply_schema_changes`. - **`max_changes`** `int`, default `20` The most changes one plan may hold. **Returns** [`SchemaOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/) A [`SchemaOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/), with its `schema` and `run()`. **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): `name` isn't a schema operation, `max_changes` isn't a positive whole number, or the client's policy allows no schema change for `preview_schema_changes` or `apply_schema_changes`. Example: ```python plan = app.operation( "preview_schema_changes", max_changes=5 ) print(plan.schema["properties"]["changes"]["maxItems"]) ``` Output: ```text 5 ``` ### Copy or delete the app For the full picture, see [Working with apps](https://matthew-williams-solutions.github.io/pyquickbase/guides/apps/). #### `App.copy()` Copy this app, with the same tables, fields, reports, and forms, and return the copy. ```python copy( name: str, *, description: str | None = None, keep_data: bool = False, keep_files: bool = False, keep_users: bool = False, assign_token: bool = True, ) -> App ``` **Parameters** - **`name`** `str`, required The copy's name. Quickbase allows several apps with the same name. - **`description`** `str | None`, optional The copy's description. - **`keep_data`** `bool`, default `False` Copy the records too. - **`keep_files`** `bool`, default `False` With `keep_data`, copy file attachments too. - **`keep_users`** `bool`, default `False` Give the copy's users the roles they have here. Otherwise the roles are copied without anyone in them. - **`assign_token`** `bool`, default `True` Let this client's token reach the copy, which a token limited to some apps can't otherwise do. **Returns** [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) An [`App`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/) for the copy. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `name` is empty. Nothing was sent. - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token's user can't copy this app. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client has a policy, which covers one app. Nothing was sent. Example: ```python staging = app.copy("Jobs (staging)") print(staging.id != app.id) ``` Output: ```text True ``` #### `App.drop()` Delete this app, with all its tables and records. Quickbase can't undo this. ```python drop(*, confirm: str) -> None ``` **Parameters** - **`confirm`** `str`, required The app's exact name, so a wrong variable can't delete the wrong app. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `confirm` isn't the app's exact name. Nothing was sent. - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token's user can't delete this app. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client has a policy, which covers one app. Nothing was sent. Example: ```python staging = app.copy("Jobs (staging)") staging.drop(confirm="Jobs (staging)") ``` ## API reference: Table Source: A [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) reads and writes one table's records and changes its fields. Get one from [`App.table()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#table), by name or ID. Getting one doesn't contact Quickbase, so a table that doesn't exist raises [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError) on first use. The examples below use `tasks`, made as shown here. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. `projects` is a table named `Job Projects`, with a `Name` field. A live test runs every example with both clients and checks its output. In the async client, [`AsyncTable`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) has the same methods, which you `await`, but `query()` and `report()` are called without it. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### What the methods return | Type | What it is | Returned by | |---|---|---| | [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) | Something in an app that runs when records change or on a schedule, such as a webhook. | [`events()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#events) | | [`DeletePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#DeletePreview) | What `delete()` would delete: a page of the matching records, and what may run. | [`preview_delete()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#preview_delete) | | [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) | A field's metadata. | [`fields()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#fields), [`create_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#create_field), [`update_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update_field), [`ensure_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_field), [`ensure_lookup_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_lookup_field), [`ensure_summary_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_summary_field), [`ensure_count_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_count_field) | | [`FieldUsage`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#FieldUsage) | Where a field is used in its app, as counted on the field's Usage page in Quickbase. | [`field_usage()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#field_usage) | | [`FileDownload`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileDownload) | A file downloaded from a file attachment field. | [`download_file()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#download_file) | | [`FileRows`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileRows) | A file's rows, converted for a table's fields, from `Table.rows_from_file()`. | [`rows_from_file()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#rows_from_file) | | [`FormulaResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FormulaResult) | The result of `table.run_formula()`. | [`run_formula()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#run_formula) | | [`Operation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/) | One operation on a table, for a model's tool call. Create one with `table.operation()`. | [`operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#operation) | | [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) | One page of query results. | [`query_page()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#query_page) | | [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record) | One record. Read values by field label or ID: `record["Status"]` or `record[7]`. | [`first()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#first), [`get()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#get) | | [`RecordIterator`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordIterator) | Yields records from a query, fetching further pages as needed. | [`query()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#query) | | [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship) | A parent-child link between two tables. | [`relationships()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#relationships), [`create_relationship()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#create_relationship), [`ensure_relationship()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_relationship) | | [`Report`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) | A report on a table. Create one with `table.report("Open tasks")`. | [`report()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#report) | | [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) | A report's metadata. | [`reports()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#reports) | | [`SummaryRow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryRow) | One group of records, from `table.summarize()`. Values are keyed by field label. | [`summarize()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#summarize) | | [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) | A table's metadata. | [`info()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#info), [`update_info()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update_info) | | [`UpdatePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#UpdatePreview) | What `update()` would do if the records stayed as they are now. | [`preview_update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#preview_update) | | [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult) | What a write did. If rows fail, `WriteError.result` holds this, including what succeeded. | [`insert()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#insert), [`update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update), [`upsert()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#upsert) | ### Read records For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `Table.query()` Iterate over matching records, fetching further pages as needed. ```python query( *, where: Expression | str | None = None, select: FieldRefs | None = None, order_by: FieldRefs | None = None, page_size: int = 1000, max_total: int | None = None, ) -> RecordIterator ``` **Parameters** - **`where`** `Expression | str | None`, optional A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a hand-written string, which gets checked. - **`select`** `FieldRefs | None`, optional The fields to return, by label, ID, or field object. Defaults to the table's default fields. Record ID# is always included. - **`order_by`** `FieldRefs | None`, optional The fields to sort by, by label, ID, or field object. Prefix a label with `-`, or negate an ID, to sort descending. Without this, records come back in Record ID# order. - **`page_size`** `int`, default `1000` Records to request per call to Quickbase. - **`max_total`** `int | None`, optional Stop after this many records in all, across pages. **Returns** [`RecordIterator`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordIterator) An iterator of [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record), which only contacts Quickbase once you start iterating. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was queried. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): An argument can't be sent as written. Nothing was queried. `page_size` and `max_total` raise this right away, and the others on first use. Example: ```python from pyquickbase import F open_tasks = tasks.query( where=F("Status") == "Open", select=["Title", "Hours"], order_by="-Hours", ) for task in open_tasks: print(task.id, task["Title"], task["Hours"]) ``` Output: ```text 2 Frame walls 12.0 1 Pour slab 8.0 ``` #### `Table.query_page()` Fetch one page of records, for controlling paging yourself. ```python query_page( *, where: Expression | str | None = None, select: FieldRefs | None = None, order_by: FieldRefs | None = None, limit: int = 100, skip: int = 0, ) -> Page ``` **Parameters** - **`where`** `Expression | str | None`, optional A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a hand-written string, which gets checked. - **`select`** `FieldRefs | None`, optional The fields to return, by label, ID, or field object. Defaults to the table's default fields. Record ID# is always included. - **`order_by`** `FieldRefs | None`, optional The fields to sort by, by label, ID, or field object. Prefix a label with `-`, or negate an ID, to sort descending. Without this, records come back in Record ID# order. - **`limit`** `int`, default `100` The most records to return. - **`skip`** `int`, default `0` How many matching records to pass over first. Use the previous page's `next_skip`. **Returns** [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) A [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) with `records`, `total`, `skip`, `has_more`, and `next_skip`. Skipping past the end returns no records rather than raising an error. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was queried. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): An argument can't be sent as written. Nothing was queried. Example: ```python page = tasks.query_page(select=["Title"], limit=2) print(page.total, page.has_more, page.next_skip) for task in page.records: print(task.id, task["Title"]) ``` Output: ```text 3 True 2 1 Pour slab 2 Frame walls ``` #### `Table.first()` Get the first matching record, or None if there isn't one. ```python first( *, where: Expression | str | None = None, select: FieldRefs | None = None, order_by: FieldRefs | None = None, ) -> Record | None ``` **Parameters** - **`where`** `Expression | str | None`, optional A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a hand-written string, which gets checked. - **`select`** `FieldRefs | None`, optional The fields to return, by label, ID, or field object. Defaults to the table's default fields. - **`order_by`** `FieldRefs | None`, optional The fields to sort by, which decide which record is first. See [`query()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#query). **Returns** [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record) `| None` The first matching [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record), or None if nothing matches. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was queried. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): An argument can't be sent as written. Nothing was queried. Example: ```python from pyquickbase import F task = tasks.first( where=F("Status") == "Open", order_by="Due Date" ) print(task["Title"] if task else "Nothing is open") ``` Output: ```text Pour slab ``` #### `Table.get()` Get a record by its Record ID#, or None if it doesn't exist. ```python get( record_id: int, *, select: FieldRefs | None = None ) -> Record | None ``` **Parameters** - **`record_id`** `int`, required The record's Record ID#. - **`select`** `FieldRefs | None`, optional The fields to return, by label, ID, or field object. Defaults to the table's default fields. **Returns** [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record) `| None` The [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record), or None if no record has this ID. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `record_id` isn't a positive integer. Nothing was sent. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was queried. Example: ```python task = tasks.get(2, select=["Title", "Status"]) print(task["Title"], task["Status"]) print(tasks.get(999)) ``` Output: ```text Frame walls Open None ``` #### `Table.count()` Count the matching records without fetching them. ```python count(where: Expression | str | None = None) -> int ``` **Parameters** - **`where`** `Expression | str | None`, optional A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a hand-written string, which gets checked. Defaults to every record. **Returns** `int` The number of matching records. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was queried. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `where` can't be sent as written. Nothing was queried. Example: ```python from pyquickbase import F print(tasks.count()) print(tasks.count(F("Status") == "Open")) ``` Output: ```text 3 2 ``` #### `Table.compile_where()` Return a condition as the query text that a query of this table would send. Use it to show a person what a query asked for, as Quickbase's own `{6.EX.'Open'}` form. This only contacts Quickbase to look up the table's fields, which the client's cache usually has. ```python compile_where(where: Expression | str) -> str ``` **Parameters** - **`where`** `Expression | str`, required A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F) or [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict), or a hand-written string, which gets checked and returned without outer whitespace. **Returns** `str` The query text. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field, or the client's policy hides it. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `where` can't be sent as written. Example: ```python from pyquickbase import F where = (F("Status") == "Open") & F("Title").contains("slab") print(tasks.compile_where(where)) ``` Output: ```text {7.EX.'Open'}AND{6.CT.'slab'} ``` ### Write records For the full picture, see [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/). #### `Table.insert()` Create records from `{label or ID: value}` dictionaries. It never changes existing ones. So it refuses a table whose key field isn't `Record ID#`, where Quickbase would change the record when a row has an existing value in that field. Use [`upsert()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#upsert) on those tables. ```python insert( records: ( Iterable[Mapping[str | int, Any]] | DataFrame | FileRows ), *, batch_size: int = 1000, timeout: float | None = None, ) -> WriteResult ``` **Parameters** - **`records`** `Iterable[Mapping[str | int, Any]] | DataFrame | FileRows`, required The records to create, as `{label or ID: value}` dictionaries. A value of `None` leaves the field empty, but a row needs at least one real value, because Quickbase can silently skip a row without one. A pandas or polars DataFrame works too, with a column for each field, where a missing value leaves the field empty, and so does a file's rows from [`rows_from_file()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#rows_from_file). - **`batch_size`** `int`, default `1000` The most records to send in one request. - **`timeout`** `float | None`, optional Seconds to allow for each step of each request, instead of the client's `timeout`, such as for a large file. It applies with your own `http_client` too. **Returns** [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult) A [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult), whose `row_ids` give each row's new Record ID#. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): A row can't be sent, such as one that sets `Record ID#` or has no real values, or the table's key field isn't `Record ID#`. Nothing was written. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): A row names a field the table doesn't have. Nothing was written. - [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError): A row failed. Other records are still written, and are listed in the error's `result`. Example: ```python from datetime import date rows = [ { "Title": "Install windows", "Due Date": date(2027, 3, 1), }, {"Title": "Paint walls", "Status": "Open"}, ] result = tasks.insert(rows) print(result.created_ids) ``` Output: ```text (4, 5) ``` #### `Table.update()` Change existing records. Each row must set `Record ID#`. A record that doesn't exist is an error rather than being created. ```python update( records: ( Iterable[Mapping[str | int, Any]] | DataFrame | FileRows ), *, batch_size: int = 1000, timeout: float | None = None, ) -> WriteResult ``` **Parameters** - **`records`** `Iterable[Mapping[str | int, Any]] | DataFrame | FileRows`, required The changes, as `{label or ID: value}` dictionaries that each set `Record ID#`. Fields left out of a row keep their values, and a value of `None` clears the field. A pandas or polars DataFrame works too, with a column for each field, where a missing value clears the field, and so does a file's rows from [`rows_from_file()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#rows_from_file). - **`batch_size`** `int`, default `1000` The most records to send in one request. - **`timeout`** `float | None`, optional Seconds to allow for each step of each request, instead of the client's `timeout`, such as for a large file. It applies with your own `http_client` too. **Returns** [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult) A [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult). A record whose values didn't change is in `unchanged_ids`, not `updated_ids`. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): A row can't be sent, such as one without `Record ID#`. Nothing was written. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): A row names a field the table doesn't have. Nothing was written. - [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError): A row failed. Other records are still written, and are listed in the error's `result`. Example: ```python result = tasks.update( [ {"Record ID#": 1, "Status": "Done"}, {"Record ID#": 2, "Status": "Open"}, # Already Open ] ) print(result.updated_ids, result.unchanged_ids) ``` Output: ```text (1,) (2,) ``` #### `Table.upsert()` Update the record whose unique `merge_on` field matches, or create one if none does. ```python upsert( records: ( Iterable[Mapping[str | int, Any]] | DataFrame | FileRows ), *, merge_on: str | int, batch_size: int = 1000, timeout: float | None = None, ) -> WriteResult ``` **Parameters** - **`records`** `Iterable[Mapping[str | int, Any]] | DataFrame | FileRows`, required The records, as `{label or ID: value}` dictionaries that each set `merge_on`. Fields left out of a row keep their values on a record that already exists. A pandas or polars DataFrame works too, with a column for each field, where a missing value leaves the field empty, and so does a file's rows from [`rows_from_file()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#rows_from_file). - **`merge_on`** `str | int`, required The field that identifies a record, by label or ID. Quickbase requires it to be marked as unique. - **`batch_size`** `int`, default `1000` The most records to send in one request. - **`timeout`** `float | None`, optional Seconds to allow for each step of each request, instead of the client's `timeout`, such as for a large file. It applies with your own `http_client` too. **Returns** [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult) A [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult), with new records in `created_ids`, changed ones in `updated_ids`, and ones that already matched in `unchanged_ids`. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): A row can't be sent, such as one without `merge_on`. Nothing was written. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): `merge_on` or a row names a field the table doesn't have. Nothing was written. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): `merge_on` isn't a unique field. Nothing was written. - [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError): A row failed. Other records are still written, and are listed in the error's `result`. Example: ```python result = tasks.upsert( [ {"Code": "T-1", "Status": "Done"}, { "Code": "T-9", "Title": "Inspect site", "Status": "Open", }, ], merge_on="Code", ) print(result.updated_ids, result.created_ids) ``` Output: ```text (1,) (4,) ``` #### `Table.delete()` Delete the matching records and return how many were deleted. ```python delete(where: Expression | str) -> int ``` **Parameters** - **`where`** `Expression | str`, required A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a hand-written string. It's required and is checked first, because Quickbase would otherwise treat a lowercase operator as "contains" and delete far more than you meant. **Returns** `int` The number of records deleted, which is 0 if none matched. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `where` can't be sent as written. Nothing was deleted. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): `where` names a field the table doesn't have. Nothing was deleted. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client's policy doesn't allow deleting here, or more records match than its `max_records_per_delete`. Nothing was deleted. Example: ```python from pyquickbase import F print(tasks.delete(F("Status") == "Done")) ``` Output: ```text 1 ``` ### Preview writes For the full picture, see [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/). #### `Table.preview_update()` Show what [`update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update) would change with the same records, without changing anything. This only reads, so it works on a read-only client. Records can change before an update, so it can't promise what the update will do. It also can't tell whether Quickbase will accept a value, such as a role that can't edit a field, or one of the table's data rules. ```python preview_update( records: ( Iterable[Mapping[str | int, Any]] | DataFrame | FileRows ), ) -> UpdatePreview ``` **Parameters** - **`records`** `Iterable[Mapping[str | int, Any]] | DataFrame | FileRows`, required The records you'd pass to [`update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update). If a record is in several of them, a later one wins. `Record ID#` must be a positive integer here, even as a string works in [`update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update). A DataFrame or a file's rows work too, as for [`update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update). **Returns** [`UpdatePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#UpdatePreview) An [`UpdatePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#UpdatePreview), with `changes`, `unchanged_ids`, `missing_ids`, and the `events` the update may set off. A value counts as a change whenever the comparison isn't certain, so [`update()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#update) may report a listed record as unchanged. A file upload is always a change. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): A row can't be sent, such as one without `Record ID#`. Nothing was read. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): A row names a field the table doesn't have. Nothing was read. Example: ```python preview = tasks.preview_update( [ {"Record ID#": 1, "Status": "Done"}, {"Record ID#": 2, "Status": "Open"}, {"Record ID#": 999, "Status": "Done"}, ] ) print( len(preview.changes), preview.unchanged_ids, preview.missing_ids, ) ``` Output: ```text 1 (2,) (999,) ``` #### `Table.preview_delete()` Show what [`delete()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#delete) would delete with the same `where`, without deleting anything. This only reads, so it works on a read-only client. Records can change before a delete, so it can't promise what the delete will do, and it can't tell whether the token may delete. ```python preview_delete( where: Expression | str, *, select: FieldRefs | None = None, order_by: FieldRefs | None = None, limit: int = 100, ) -> DeletePreview ``` **Parameters** - **`where`** `Expression | str`, required A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a hand-written string, checked as [`delete()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#delete) checks it. It's required, so that leaving it out can't look like a safe preview. - **`select`** `FieldRefs | None`, optional The fields to return. Defaults to the table's default fields. - **`order_by`** `FieldRefs | None`, optional Which matching records come first. See [`query()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#query). - **`limit`** `int`, default `100` The most records to return. `total` still counts every match. **Returns** [`DeletePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#DeletePreview) A [`DeletePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#DeletePreview): a page whose `total` is how many records would be deleted, and whose `events` are what deleting them may set off. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `where` can't be sent as written, or is None. Nothing was queried. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was queried. Example: ```python from pyquickbase import F page = tasks.preview_delete( F("Status") == "Done", select=["Title"] ) print(page.total, [task["Title"] for task in page.records]) ``` Output: ```text 1 ['Hang drywall'] ``` ### Summarize and calculate For the full picture, see [Formulas](https://matthew-williams-solutions.github.io/pyquickbase/guides/formulas/). #### `Table.summarize()` Count, total, and compare records, overall or per group, without a saved report. A summary that only counts, with no `group_by` or statistics, asks Quickbase how many records match, in one request, and reads none. Any other summary reads every matching record, in pages of up to 25,000. Records can change between pages, so on a table in use, the result may not match any one moment. A field's values are added exactly, and returned as `float`. A median keeps every value of its field in memory until the summary ends, so give it `max_records_scanned` on a large table. ```python summarize( group_by: SummaryGroups | None = None, *, where: Expression | str | None = None, sum: FieldRefs | None = None, avg: FieldRefs | None = None, median: FieldRefs | None = None, std_dev: FieldRefs | None = None, min: FieldRefs | None = None, max: FieldRefs | None = None, distinct_count: FieldRefs | None = None, compare_periods: bool = False, max_records_scanned: int | None = None, page_size: int | None = None, ) -> list[SummaryRow] ``` **Parameters** - **`group_by`** `SummaryGroups | None`, optional The fields to group by, and [`Period`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Period) for dates. Without this, the result is one row for all the matching records. - **`where`** `Expression | str | None`, optional A condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F) or [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict), or a hand-written string. - **`sum`** `FieldRefs | None`, optional Numeric fields to add up. Empty values are skipped. - **`avg`** `FieldRefs | None`, optional Numeric fields to average. Empty values are skipped. - **`median`** `FieldRefs | None`, optional Numeric fields to find the middle value of. Empty values are skipped. - **`std_dev`** `FieldRefs | None`, optional Numeric fields to find the sample standard deviation of. Empty values are skipped. - **`min`** `FieldRefs | None`, optional Numeric, date, timestamp, or time of day fields to find the lowest of. - **`max`** `FieldRefs | None`, optional Numeric, date, timestamp, or time of day fields to find the highest of. - **`distinct_count`** `FieldRefs | None`, optional Fields to count the different values of, such as how many clients have records. Empty values are skipped. - **`compare_periods`** `bool`, default `False` Say how each group changed since the period before, in its `change`. `group_by` must have exactly one [`Period`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Period), and the other fields in it stay the same, so `[Period("Due", "month"), "Owner"]` compares each owner's months. A filter that cuts a period short makes that period look smaller. - **`max_records_scanned`** `int | None`, optional Raise [`TooManyRecordsError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#TooManyRecordsError) if more records match than this, without reading them. A summary that only counts reads no records, so this doesn't limit it. It never returns a partial result. An agent's tool should always set this. A client's policy can set a limit of its own, `max_records_scanned`. - **`page_size`** `int | None`, optional Records to request per call to Quickbase. By default it's set by the fields the summary reads, from 25,000 for one field down to 1,000, so that a page takes about 25 MB of memory however wide it is. **Returns** `list[` [`SummaryRow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryRow) `]` A [`SummaryRow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryRow) for each group, sorted by the group's values with empty ones last. If nothing matches, that's an empty list, or without `group_by`, one row with a `count` of 0. **Raises** - [`TooManyRecordsError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#TooManyRecordsError): More records match than `max_records_scanned`. No records were read. It's a [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError). - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): An argument can't be used, such as a text field in `sum`. No records were read. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. No records were read. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): More records match than the client's policy allows summarizing, or it doesn't allow a field. No records were read. - [`InvalidResponseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidResponseError): A value isn't what its field's type says it should be. Example: ```python rows = tasks.summarize("Status", sum="Hours") for row in rows: print(row.group["Status"], row.count, row.sum["Hours"]) ``` Output: ```text Done 1 6.0 Open 2 20.0 ``` #### `Table.run_formula()` Run a formula against this table, without saving it. Don't run formula text you don't control: a formula can read other tables, whichever table you run it on. ```python run_formula( formula: str, *, record_id: int | None = None ) -> FormulaResult ``` **Parameters** - **`formula`** `str`, required The formula's text, in Quickbase's formula language. It can't be blank. - **`record_id`** `int | None`, optional The record whose values the formula reads. It's required if the formula uses fields. **Returns** [`FormulaResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FormulaResult) A [`FormulaResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FormulaResult). Its `value` is the result as a Python value where that's safe, and `text` is exactly what Quickbase sent. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `formula` is blank, or `record_id` isn't a positive integer. Nothing was sent. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase can't run the formula, and its message says why. - [`NotFoundError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NotFoundError): The record doesn't exist. Example: ```python result = tasks.run_formula("[Hours] * 2", record_id=1) print(result.value) ``` Output: ```text 16 ``` ### Look up the table's details For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `Table.info()` Get the table's metadata, from the client's cache of the app's tables. ```python info() -> TableInfo ``` **Returns** [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) A [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo), with the table's `id` and `name`. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, even after checking again. Example: ```python info = tasks.info() print(info.name) ``` Output: ```text Job Tasks ``` #### `Table.fields()` List the table's fields, from the cache unless `refresh` is set. ```python fields(*, refresh: bool = False) -> list[Field] ``` **Parameters** - **`refresh`** `bool`, default `False` Ask Quickbase instead of using the client's cache, which keeps metadata for five minutes by default. A change made through this client updates the cache, so this is for changes made elsewhere, such as in the Quickbase interface. **Returns** `list[` [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) `]` A [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) for each of the table's fields, including built-in ones like `Record ID#`. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table. Example: ```python for field in tasks.fields(): if field.label in ("Title", "Status"): print(field.id, field.label, field.type) ``` Output: ```text 6 Title text 7 Status text-multiple-choice ``` #### `Table.field_usage()` Say where fields are used, such as in reports, forms, formulas, and pipelines. These are the counts on each field's Usage page in Quickbase, which help to judge what deleting or changing a field would break. ```python field_usage( fields: str | int | Sequence[str | int] | None = None, ) -> list[FieldUsage] ``` **Parameters** - **`fields`** `str | int | Sequence[str | int] | None`, optional One field or a list of them, by label or ID. Defaults to every field. **Returns** `list[` [`FieldUsage`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#FieldUsage) `]` A [`FieldUsage`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#FieldUsage) for each field, in the order asked for, or the table's order. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): A field doesn't exist. Nothing was read. - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token isn't an administrator's. Example: ```python for usage in tasks.field_usage(["Title", "Code"]): print(usage.label, usage.counts["reports"]) ``` Output: ```text Title 2 Code 2 ``` #### `Table.relationships()` List the relationships in which this table is the child. A parent table lists none. ```python relationships( *, refresh: bool = False ) -> list[Relationship] ``` **Parameters** - **`refresh`** `bool`, default `False` Ask Quickbase instead of using the client's cache, which keeps metadata for five minutes by default. A change made through this client updates the cache, so this is for changes made elsewhere, such as in the Quickbase interface. **Returns** `list[` [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship) `]` A [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship) for each parent this table links to, or an empty list. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table. - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token's role can't edit the app's structure. Example: ```python for relationship in tasks.relationships(): print(relationship.foreign_key_field_id) ``` Output: ```text 12 ``` #### `Table.reports()` List the table's reports, from the cache unless `refresh` is set. ```python reports(*, refresh: bool = False) -> list[ReportInfo] ``` **Parameters** - **`refresh`** `bool`, default `False` Ask Quickbase instead of using the client's cache, which keeps metadata for five minutes by default. A change made through this client updates the cache, so this is for changes made elsewhere, such as in the Quickbase interface. **Returns** `list[` [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) `]` A [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) for each report, including ones Quickbase can't run through the API, whose `incomplete` is true. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table. Example: ```python for report in tasks.reports(): print(report.name, report.type) ``` Output: ```text List All table List Changes table ``` #### `Table.report()` Get a report on this table by name or ID. It's checked when first used. This doesn't contact Quickbase, so a report that doesn't exist raises [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError) on first use. ```python report(name_or_id: str) -> Report ``` **Parameters** - **`name_or_id`** `str`, required The report's exact name, or its ID, which takes priority over a name. **Returns** [`Report`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) A [`Report`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/), for running it or getting its details. Example: ```python report = tasks.report("List All") info = report.info() print(info.name) ``` Output: ```text List All ``` #### `Table.events()` List the webhooks, Quickbase actions, notifications, subscriptions, reminders, and record-triggered automations that watch this table. See [`App.events()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#events). ```python events() -> list[AppEvent] ``` **Returns** `list[` [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) `]` An [`AppEvent`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppEvent) for each, active or not. **Raises** - [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError): The token isn't an administrator's. Example: ```python events = tasks.events() print(len(events)) ``` Output: ```text 0 ``` #### `Table.resolve_field_ids()` Turn field labels or IDs into IDs. ```python resolve_field_ids( fields: Sequence[str | int], ) -> list[int] ``` **Parameters** - **`fields`** `Sequence[str | int]`, required The fields, by label or ID, in any mix. **Returns** `list[int]` The field IDs, in the same order as [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields). **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Example: ```python print(tasks.resolve_field_ids(["Title", "Status", 3])) ``` Output: ```text [6, 7, 3] ``` ### Import a CSV or Excel file For the full picture, see [CSV and Excel files](https://matthew-williams-solutions.github.io/pyquickbase/guides/spreadsheets/). #### `Table.rows_from_file()` Read a CSV or Excel file's rows, converting each value for its field, to write them. The first row names each column's field, by label. Each value is converted by its field's type, from text such as `1,234.5`, `45%` for a percent, `yes`, `2027-01-15`, or a user's email, or from Excel's own dates and numbers. A value that can't be converted is an error with its row and column, and nothing is written while there are any. Quickbase would otherwise empty a number field given text it can't read, without an error. It reads the whole file into memory, and contacts Quickbase only for the table's fields. The async client reads it on a worker thread, so a large file doesn't stop the event loop. ```python rows = tasks.rows_from_file("tasks.csv") for error in rows.errors: print(error) tasks.insert(rows) ``` ```python rows_from_file( source: FileLike, *, columns: ColumnMap | None = None, skip_read_only: bool = False, format: Optional[Literal["csv", "xlsx"]] = None, sheet: str | None = None, tz: datetime.tzinfo = datetime.timezone.utc, date_format: str | None = None, datetime_format: str | None = None, encoding: str = "utf-8-sig", delimiter: str | None = None, max_rows: int | None = None, max_unzipped_bytes: int | None = 1000000000, ) -> FileRows ``` **Parameters** - **`source`** `FileLike`, required A path, or an open file: text for CSV, and binary for Excel. - **`columns`** `ColumnMap | None`, optional Which field each column goes in, by label or ID, or None to skip a column, such as `{"Task": "Title", "Notes": None}`. Other columns go in the field with their exact name. - **`skip_read_only`** `bool`, default `False` Skip columns for fields that can't be written, such as formulas and Date Created, instead of raising an error, as a file from `to_csv()` has them. - **`format`** `Optional[Literal['csv', 'xlsx']]`, optional `csv` or `xlsx`. By default it's `xlsx` for an `.xlsx` or `.xlsm` path or a binary file, and `csv` otherwise. - **`sheet`** `str | None`, optional For Excel, the worksheet's name. Defaults to the first. - **`tz`** `datetime.tzinfo`, default `datetime.timezone.utc` The time zone of a timestamp without one, such as one from Excel. - **`date_format`** `str | None`, optional How dates are written, for `datetime.strptime()`, such as `%m/%d/%Y`. Defaults to ISO 8601, `2027-01-15`. - **`datetime_format`** `str | None`, optional How timestamps are written, as for `date_format`. Defaults to ISO 8601. - **`encoding`** `str`, default `'utf-8-sig'` A CSV path's text encoding. The default also reads UTF-8 that starts with a byte order mark, as Excel writes it. - **`delimiter`** `str | None`, optional What separates a CSV's values. Defaults to a tab for a `.tsv` or `.tab` path, and a comma otherwise. - **`max_rows`** `int | None`, optional The most rows with values to read, or None for no limit. Set this for a file you didn't make, as reading stops at the limit. Blank rows don't count. - **`max_unzipped_bytes`** `int | None`, default `1000000000` The most an Excel file may unpack to, or None for no limit. It's a zip, so a small file can unpack to something huge. **Returns** [`FileRows`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileRows) A [`FileRows`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileRows), with the `rows` to write, each one's row number, and every error. **Raises** - [`FileTooLargeError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#FileTooLargeError): The file is over `max_rows` or `max_unzipped_bytes`. - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): The file can't be read as a table: it has no header row, names a column twice, has a column that isn't a field, or writes a field that can't be written. Fix these before anything else. Example: ```python import io text = io.StringIO( "Title,Hours,Due Date\nPour footings,1,2027-03-01\n" ) rows = tasks.rows_from_file(text) print(rows.ok, len(rows), rows.row_numbers) result = tasks.insert(rows) print(len(result.created_ids)) ``` Output: ```text True 1 (2,) 1 ``` ### Work with files For the full picture, see [File attachments](https://matthew-williams-solutions.github.io/pyquickbase/guides/files/). #### `Table.download_file()` Download a file from a record, by default its latest version. Quickbase keeps a limited number of versions, so an older one may be gone. ```python download_file( record_id: int, field: str | int, *, version: int | None = None, timeout: float | None = None, ) -> FileDownload ``` **Parameters** - **`record_id`** `int`, required The Record ID# of the record that holds the file. - **`field`** `str | int`, required The file attachment field, by label or ID. - **`version`** `int | None`, optional The version to download, starting at 1. Defaults to the latest. - **`timeout`** `float | None`, optional Seconds to allow for each step of the request, instead of the client's `timeout`, such as for a large file. It applies with your own `http_client` too. **Returns** [`FileDownload`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileDownload) A [`FileDownload`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileDownload), with the file's `name`, `data`, and `content_type`. Don't trust `name` as a path, or `content_type` as a description of what's in the file. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `record_id` or `version` isn't a positive integer, `timeout` isn't a positive number, or `field` isn't a file attachment field. Nothing was sent. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was sent. - [`NotFoundError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NotFoundError): The record, version, or file doesn't exist. Example: ```python download = tasks.download_file(1, "Attachment") print(download.file_name, download.data) ``` Output: ```text spec.txt b'one' ``` #### `Table.delete_file_version()` Delete one version of a file. Deleting the only version leaves the field empty. The record stays, and the other versions keep their numbers. ```python delete_file_version( record_id: int, field: str | int, *, version: int ) -> None ``` **Parameters** - **`record_id`** `int`, required The Record ID# of the record that holds the file. - **`field`** `str | int`, required The file attachment field, by label or ID. - **`version`** `int`, required The version to delete, starting at 1. It's required, because Quickbase treats version 0 as the latest and deletes it. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `record_id` or `version` isn't a positive integer, or `field` isn't a file attachment field. Nothing was sent. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was sent. - [`NotFoundError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NotFoundError): The record or version doesn't exist. Example: ```python tasks.delete_file_version(1, "Attachment", version=1) ``` ### Offer an operation to a model For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `Table.operation()` Make one operation on this table into a tool for a model, with its JSON Schema. The schema describes the arguments a model may send, and `run()` checks them against the same lists before calling the table, so a model can't use anything it wasn't offered. The lists hold only what the client's policy allows, narrowed by [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields). ```python search = tasks.operation( "query", fields=["Title", "Status"] ) tool = { "name": "search_tasks", "input_schema": search.schema, } scope = F("Project") == project page = search.run(arguments, scope=scope) ``` See [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/operations/#giving-a-model-operations) for what each operation takes. ```python operation( name: OperationName, *, fields: Sequence[str | int] | None = None, field_descriptions: Mapping[str | int, str] | None = None, reports: Sequence[str] | None = None, report_descriptions: Mapping[str, str] | None = None, formula: str | None = None, max_records_per_call: int | None = None, max_records_per_write: int = 100, scope: Expression | None = None, require_preview: bool = False, max_default_fields: int | None = None, ) -> Operation ``` **Parameters** - **`name`** `OperationName`, required What the operation does, such as `query` or `insert`. Each runs the method of the same name, and `run_report` and `run_summary_report` run a report. - **`fields`** `Sequence[str | int] | None`, optional The only fields it may use, by label or ID. Defaults to every field the client may use for the operation. - **`field_descriptions`** `Mapping[str | int, str] | None`, optional What fields mean, as `{label or ID: text}`, which the schema shows the model. Each must be for a field the operation offers. - **`reports`** `Sequence[str] | None`, optional The reports `run_report` or `run_summary_report` may run, by ID. Defaults to every report of that kind the client may run. - **`report_descriptions`** `Mapping[str, str] | None`, optional What reports are for, as `{ID: text}`, which the schema shows the model. Each must be for a report the operation offers. - **`formula`** `str | None`, optional For `run_formula`, the formula's text, which your code fixes. The model only chooses the record, as a formula can read any table. - **`max_records_per_call`** `int | None`, optional The most records one call may return, or for `delete`, may match. A delete that matches more is refused. Give `preview_delete` the same number, and its result says when a delete would be refused. Defaults to 100, or for `summarize`, 10,000 records read. The client's policy can lower it, and its `max_paging_depth` also limits how far `skip` can page. - **`max_records_per_write`** `int`, default `100` The most records one `insert` or `update` may send. - **`scope`** `Expression | None`, optional A condition your code fixes, such as the user's project, that every record the operation reaches must match, as `run()`'s `scope` does. Given here, the schema also tells the model what it is, leaves its fields out of what a write may set, and refuses a write that sets one with a message that says why. An `insert` needs a scope of only `==` conditions joined with `&`, whose fields the client's policy lets it set. A report or formula can't take one. - **`require_preview`** `bool`, default `False` For `update` and `delete`: run only with the `confirmation` that `preview_update` or `preview_delete` returns for exactly the same arguments, within 15 minutes, so a model can't skip the preview. A delete is also refused if how many records match has changed since. Make the preview with the same client and `scope`. - **`max_default_fields`** `int | None`, optional For `query`: the most of the table's default fields to return when the model doesn't name any in `select`. A table with more returns its first ones, and [`tool_result`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#tool_result) says how many it left out. Defaults to every one. **Returns** [`Operation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/) An [`Operation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/), with its `schema` and `run()`. **Raises** - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client's policy doesn't allow this operation at all. - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): The table has nothing for it, such as no file fields, or `formula` is missing or given for another operation. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) or `field_descriptions` names a field the client can't see, or one the operation doesn't offer. - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError): `reports` has an ID this table doesn't have, or `report_descriptions` has one the operation doesn't offer. Example: ```python count = tasks.operation("count", fields=["Status"]) arguments = { "where": {"field": "Status", "op": "==", "value": "Open"} } print(count.run(arguments)) ``` Output: ```text 2 ``` ### Link to the Quickbase interface For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `Table.url()` Link to the table's home page in Quickbase. This only contacts Quickbase to look up the table's ID, as any method does, and the client's cache usually has it. ```python url() -> str ``` **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/td ``` #### `Table.record_url()` Link to a record's page in Quickbase, for viewing it or editing it. This doesn't check that the record exists, so a link to one that doesn't opens an error page. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python record_url( record_id: int, *, edit: bool = False ) -> str ``` **Parameters** - **`record_id`** `int`, required The record's Record ID#. - **`edit`** `bool`, default `False` Link to the page that edits the record, rather than the one that shows it. **Returns** `str` The URL, on the client's realm. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `record_id` isn't a positive integer. - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.record_url(1)) print(tasks.record_url(1, edit=True)) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/dr?rid=1 https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/er?rid=1 ``` #### `Table.record_url_template()` Link to any record's page in Quickbase, with `{record_id}` where its ID goes. A tool can send this once, rather than a link for each record, and each record's `Record ID#` fills it in. The placeholder is text, not part of a working URL, so replace it with `template.replace("{record_id}", str(record_id))`. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python record_url_template(*, edit: bool = False) -> str ``` **Parameters** - **`edit`** `bool`, default `False` Link to the page that edits a record, rather than the one that shows it. **Returns** `str` The URL, on the client's realm, with `{record_id}` for the record's ID. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python template = tasks.record_url_template() print(template) print(template.replace("{record_id}", "2")) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/dr?rid={record_id} https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/dr?rid=2 ``` #### `Table.new_record_url()` Link to the page in Quickbase that adds a record to the table. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python new_record_url() -> str ``` **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.new_record_url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/nwr ``` #### `Table.field_url()` Link to the page in Quickbase that edits a field's settings. It only contacts Quickbase to look up the table's and field's IDs, as any method does, and the client's cache usually has them. ```python field_url(field: str | int) -> str ``` **Parameters** - **`field`** `str | int`, required The field, by label or ID. **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field, or the client's policy hides it. - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.field_url("Status")) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/mf?fid=7 ``` #### `Table.fields_url()` Link to the table's list of fields in Quickbase. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python fields_url() -> str ``` **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.fields_url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/listfields ``` #### `Table.relationships_url()` Link to the table's relationships in Quickbase. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python relationships_url() -> str ``` **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.relationships_url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/Relationships ``` #### `Table.settings_url()` Link to the table's settings in Quickbase, such as its name and key field. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python settings_url() -> str ``` **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python print(tasks.settings_url()) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/KeyProps ``` #### `Table.form_url()` Link to the page in Quickbase that edits one of the table's forms. The API can't list forms, so this takes the form's ID, the `dfid` in its address in Quickbase, and doesn't check that the form exists. It only contacts Quickbase to look up the table's ID, as [`url()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#url) does. ```python form_url(form_id: int) -> str ``` **Parameters** - **`form_id`** `int`, required The form's ID. **Returns** `str` The URL, on the client's realm. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `form_id` isn't a positive integer. - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or the client's policy hides it. Example: ```python # A form's ID is the dfid in its address in Quickbase. print(tasks.form_url(10)) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/DFormProps?dfid=10 ``` ### Change the schema For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `Table.create_field()` Create a field and return it. Quickbase sets `required` and `unique` in a second request, so if that one fails the field exists without them. Use [`ensure_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_field) to repair it. ```python create_field( label: str, type: str, *, choices: Sequence[str] | None = None, required: bool | None = None, unique: bool | None = None, properties: Mapping[str, Any] | None = None, formula: str | None = None, help_text: str | None = None, bold: bool | None = None, no_wrap: bool | None = None, find_enabled: bool | None = None, appears_by_default: bool | None = None, add_to_forms: bool | None = None, ) -> Field ``` **Parameters** - **`label`** `str`, required The new field's label. Another field can't have the same label, ignoring case. - **`type`** `str`, required One of `text`, `text-multiple-choice`, `text-multi-line`, `rich-text`, `numeric`, `currency`, `rating`, `percent`, `multitext`, `email`, `url`, `duration`, `date`, `datetime`, `timestamp`, `timeofday`, `checkbox`, `user`, `multiuser`, `address`, `phone`, or `file`. A `datetime` field reports its type as `timestamp`. - **`choices`** `Sequence[str] | None`, optional The options of a `text-multiple-choice` or `multitext` field. Quickbase still accepts other values unless `properties` sets `allowNewChoices` to False. - **`required`** `bool | None`, optional Whether the field must have a value. Quickbase refuses this if the table has records, because the new field is empty on all of them. - **`unique`** `bool | None`, optional Whether values must be different on every record. - **`properties`** `Mapping[str, Any] | None`, optional Other settings, sent as they are, such as `{"decimalPlaces": 2}`. Quickbase refuses a key it doesn't know. - **`formula`** `str | None`, optional Makes this a formula field. `type` must match the formula's result, and can't be `timestamp` or `text-multi-line`. Use `datetime` for a timestamp formula. - **`help_text`** `str | None`, optional The help Quickbase shows beside the field on forms. - **`bold`** `bool | None`, optional Whether forms and reports show the field's values in bold. - **`no_wrap`** `bool | None`, optional Whether forms and reports keep the field's values on one line. - **`find_enabled`** `bool | None`, optional Whether the field's values are searched by Quickbase's search box. - **`appears_by_default`** `bool | None`, optional Whether a query or report without chosen columns returns the field. - **`add_to_forms`** `bool | None`, optional Whether Quickbase adds the field to the table's forms. Quickbase doesn't report this afterward. **Returns** [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) The new [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field). **Raises** - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase refused the field, such as for an unknown type, a label that's already used, or `choices` on a type without them. If it refused `required` or `unique`, the field was still created, and the message says so. - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `formula` is blank, or a setting has the wrong type. Nothing was sent. Example: ```python field = tasks.create_field( "Priority", "text-multiple-choice", choices=["Low", "Medium", "High"], ) print(field.label, field.type) ``` Output: ```text Priority text-multiple-choice ``` #### `Table.update_field()` Change a field's settings and return it. Anything left as None stays as it is. A field's type can't be changed here. ```python update_field( field: str | int, *, label: str | None = None, required: bool | None = None, unique: bool | None = None, properties: Mapping[str, Any] | None = None, formula: str | None = None, help_text: str | None = None, bold: bool | None = None, no_wrap: bool | None = None, find_enabled: bool | None = None, appears_by_default: bool | None = None, ) -> Field ``` **Parameters** - **`field`** `str | int`, required The field's label or ID. - **`label`** `str | None`, optional A new label. - **`required`** `bool | None`, optional Whether the field must have a value. Quickbase refuses `True` if any record has this field empty. - **`unique`** `bool | None`, optional Whether values must be different on every record. Quickbase refuses `True` if the field already holds duplicates. - **`properties`** `Mapping[str, Any] | None`, optional Other settings, sent as they are. `{"choices": [...]}` replaces the whole list of choices. - **`formula`** `str | None`, optional New formula text, only for a formula field. It can't be blank, because Quickbase would accept that and empty the field's values. - **`help_text`** `str | None`, optional The help Quickbase shows beside the field on forms. - **`bold`** `bool | None`, optional Whether forms and reports show the field's values in bold. - **`no_wrap`** `bool | None`, optional Whether forms and reports keep the field's values on one line. - **`find_enabled`** `bool | None`, optional Whether the field's values are searched by Quickbase's search box. - **`appears_by_default`** `bool | None`, optional Whether a query or report without chosen columns returns the field. **Returns** [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) The [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field), with its new settings. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field. Nothing was sent. - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): There's nothing to change, `formula` is blank, or a setting has the wrong type. Nothing was sent. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase refused the change, and the field is as it was. Example: ```python field = tasks.update_field( "Hours", label="Estimated Hours" ) print(field.label) ``` Output: ```text Estimated Hours ``` #### `Table.delete_fields()` Delete fields and all their data. ```python delete_fields( fields: str | int | Sequence[str | int], ) -> None ``` **Parameters** - **`fields`** `str | int | Sequence[str | int]`, required One field or a list of them, by label or ID. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): A field doesn't exist. Nothing was deleted. - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): The list is empty. Nothing was sent. - [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError): A field couldn't be deleted, such as a built-in field. The others in the list were still deleted. Example: ```python tasks.delete_fields("Code") ``` #### `Table.ensure_field()` Return the field with this label, creating it or changing it to match what you pass. This makes a setup script safe to run again, and repairs a field whose `required` or `unique` didn't get set, because Quickbase sets those in a second request. The arguments are the same as for [`create_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#create_field), and anything left as None is ignored on an existing field. `choices` are compared ignoring order. ```python ensure_field( label: str, type: str, *, choices: Sequence[str] | None = None, required: bool | None = None, unique: bool | None = None, properties: Mapping[str, Any] | None = None, formula: str | None = None, help_text: str | None = None, bold: bool | None = None, no_wrap: bool | None = None, find_enabled: bool | None = None, appears_by_default: bool | None = None, ) -> Field ``` **Parameters** - **`label`** `str`, required The field's label, ignoring case. - **`type`** `str`, required The field's type. `datetime` matches a field that reports `timestamp`. - **`choices`** `Sequence[str] | None`, optional The options of a `text-multiple-choice` or `multitext` field. - **`required`** `bool | None`, optional Whether the field must have a value. Quickbase refuses `True` if any record has this field empty, which includes a new field on a table with records. - **`unique`** `bool | None`, optional Whether values must be different on every record. - **`properties`** `Mapping[str, Any] | None`, optional Other settings, sent as they are. - **`formula`** `str | None`, optional The text of a formula field. - **`help_text`** `str | None`, optional The help Quickbase shows beside the field on forms. - **`bold`** `bool | None`, optional Whether forms and reports show the field's values in bold. - **`no_wrap`** `bool | None`, optional Whether forms and reports keep the field's values on one line. - **`find_enabled`** `bool | None`, optional Whether the field's values are searched by Quickbase's search box. - **`appears_by_default`** `bool | None`, optional Whether a query or report without chosen columns returns the field. **Returns** [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) The [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field), whether it was found, created, or changed. **Raises** - [`SchemaMismatchError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaMismatchError): The field exists with a different type, because Quickbase can't always convert one. It wasn't changed. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase refused the field or the change. Example: ```python field = tasks.ensure_field("Priority", "text") again = tasks.ensure_field("Priority", "text") print(field.id == again.id) ``` Output: ```text True ``` #### `Table.create_relationship()` Make this table a child of `parent`, with a new foreign key field on this table. Quickbase also adds two fields to the parent: a list of the child's records, and a link to add one. Calling this again makes another relationship, even with the same label, so use [`ensure_relationship()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_relationship) in a script that may run twice. ```python create_relationship( parent: Table, *, foreign_key: str, lookup_fields: Sequence[str | int] = (), ) -> Relationship ``` **Parameters** - **`parent`** `Table`, required The [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) to link to, which becomes the parent. - **`foreign_key`** `str`, required The label of the new field on this table, which holds the Record ID# of each record's parent. - **`lookup_fields`** `Sequence[str | int]`, default `()` Fields of the parent to show on this table, by label or ID. Quickbase names each new field after the foreign key and the field, such as `Project - Name`. **Returns** [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship) The [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship). Its `lookup_field_ids` are the new fields on this table, so don't pass them back as `lookup_fields`. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The parent has no such field. Nothing was sent. Example: ```python relationship = tasks.create_relationship( projects, foreign_key="Project", lookup_fields=["Name"], ) print(relationship.foreign_key_field_id) ``` Output: ```text 12 ``` #### `Table.ensure_relationship()` Return this table's relationship with `parent`, creating it if it doesn't exist. Use the result with [`ensure_lookup_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_lookup_field) and [`ensure_summary_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_summary_field). ```python ensure_relationship( parent: Table, *, foreign_key: str ) -> Relationship ``` **Parameters** - **`parent`** `Table`, required The [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) to link to, which becomes the parent. - **`foreign_key`** `str`, required The label of the field on this table that holds the Record ID# of each record's parent, ignoring case when looking for an existing relationship. **Returns** [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship) The existing or new [`Relationship`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Relationship). **Raises** - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): This app has no such table, or no such parent. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase refused to create the relationship. - [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError): The client's policy doesn't allow it. Nothing was created. Example: ```python relationship = tasks.ensure_relationship( projects, foreign_key="Project" ) again = tasks.ensure_relationship( projects, foreign_key="Project" ) print(relationship.id == again.id) ``` Output: ```text True ``` #### `Table.ensure_lookup_field()` Return this table's lookup of `parent_field`, creating it if it doesn't exist. Call this on the child table, and the new field is on the child. It's found by what it points at, so renaming it doesn't make a script create another. ```python ensure_lookup_field( relationship: Relationship, parent_field: str | int, *, label: str | None = None, ) -> Field ``` **Parameters** - **`relationship`** `Relationship`, required The relationship, from [`ensure_relationship()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_relationship). - **`parent_field`** `str | int`, required The parent's field to look up, by label or ID. - **`label`** `str | None`, optional A label for the lookup, since Quickbase names one after the foreign key and the field, such as `Project - Name`. An existing lookup is renamed to match. **Returns** [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) The lookup [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field), on this table. **Raises** - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The parent has no such field. Nothing was sent. Example: ```python relationship = tasks.ensure_relationship( projects, foreign_key="Project" ) field = tasks.ensure_lookup_field( relationship, "Name", label="Project Name" ) print(field.label) ``` Output: ```text Project Name ``` #### `Table.ensure_summary_field()` Return the summary of this table's `child_field` on the parent, creating it if needed. Call this on the child table, and the new field is on the parent. **A summary with no child records is empty, not 0**, so wrap it in `Nz()` in a formula if your code expects a number. To count the child records, use [`ensure_count_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_count_field). ```python ensure_summary_field( relationship: Relationship, child_field: str | int, label: str, *, function: str = "sum", where: Expression | str | None = None, ) -> Field ``` **Parameters** - **`relationship`** `Relationship`, required The relationship, from [`ensure_relationship()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_relationship). - **`child_field`** `str | int`, required This table's field to summarize, by label or ID. - **`label`** `str`, required The label of the summary field on the parent. - **`function`** `str`, default `'sum'` The calculation: `sum`, `avg`, `max`, `min`, `std_dev`, `distinct_count`, `combined_text`, or `combined_user`. Quickbase's names, such as `STD-DEV`, work too. - **`where`** `Expression | str | None`, optional Only combine the child records that match, as a condition on this table built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a string such as `{7.EX.true}`. It's checked first, as Quickbase saves one it can't use and the summary is then always empty. **Returns** [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) The summary [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field), on the parent. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `function` isn't one of these, or is `count`, which takes no field. Nothing was sent. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): This table has no such field. Nothing was sent. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `where` can't be sent as written. Nothing was sent. - [`SchemaMismatchError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaMismatchError): The label is already used on the parent by anything but this summary. It wasn't changed. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase can't apply `function` to this field, such as `sum` of a checkbox. Example: ```python relationship = tasks.ensure_relationship( projects, foreign_key="Project" ) field = tasks.ensure_summary_field( relationship, "Hours", "Total Hours" ) print(field.label) ``` Output: ```text Total Hours ``` #### `Table.ensure_count_field()` Return the count of this table's records for each parent, creating it if needed. Call this on the child table, and the new field is on the parent. Unlike other summaries, a parent with no child records counts 0, not empty. ```python ensure_count_field( relationship: Relationship, label: str, *, where: Expression | str | None = None, ) -> Field ``` **Parameters** - **`relationship`** `Relationship`, required The relationship, from [`ensure_relationship()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_relationship). - **`label`** `str`, required The label of the count field on the parent. - **`where`** `Expression | str | None`, optional Only count the records that match, as in [`ensure_summary_field()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#ensure_summary_field). **Returns** [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) The count [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field), on the parent. **Raises** - [`SchemaMismatchError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaMismatchError): The label is already used on the parent by anything but this count. It wasn't changed. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): `where` uses a field this table doesn't have. Nothing was sent. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `where` can't be sent as written. Nothing was sent. Example: ```python relationship = tasks.ensure_relationship( projects, foreign_key="Project" ) field = tasks.ensure_count_field( relationship, "Task Count" ) print(field.label, field.summary_function) ``` Output: ```text Task Count COUNT ``` #### `Table.delete_relationship()` Delete a relationship in which this table is the child. Quickbase also deletes its lookup fields on this table, and its summary fields and the fields that list and add child records on the parent. The foreign key field stays, with its values, as a plain number field. ```python delete_relationship( relationship: Relationship, ) -> None ``` **Parameters** - **`relationship`** `Relationship`, required The relationship, from [`relationships()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#relationships). **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): This table isn't the relationship's child. Nothing was sent. - [`NotFoundError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NotFoundError): The relationship doesn't exist. Example: ```python relationship = (tasks.relationships())[0] tasks.delete_relationship(relationship) print(tasks.relationships()) ``` Output: ```text [] ``` #### `Table.update_info()` Change the table's name or descriptions, and return its new details. Anything left as None stays as it is. This [`Table`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/) keeps working after a rename, even if it was found by its old name. ```python update_info( *, name: str | None = None, description: str | None = None, single_record_name: str | None = None, plural_record_name: str | None = None, ) -> TableInfo ``` **Parameters** - **`name`** `str | None`, optional A new name. Another table in the app can't have it. - **`description`** `str | None`, optional A new description. - **`single_record_name`** `str | None`, optional What the table calls one record, such as `Project`. - **`plural_record_name`** `str | None`, optional What the table calls several records, such as `Projects`. **Returns** [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo) The table's [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo), with its new settings. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): There's nothing to change. Nothing was sent. - [`BadRequestError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#BadRequestError): Quickbase refused the change, such as for a name another table has. Example: ```python info = tasks.update_info( description="Work on each job, one record per task.", ) print(info.description) ``` Output: ```text Work on each job, one record per task. ``` #### `Table.drop()` Delete this table and all its records. ```python drop(*, confirm: str) -> None ``` **Parameters** - **`confirm`** `str`, required The table's exact name, so a wrong variable can't drop the wrong table. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `confirm` isn't the table's exact name. Nothing was sent. Example: ```python tasks.drop(confirm="Job Tasks") ``` ## API reference: Report Source: A [`Report`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) runs a report that was built in the Quickbase interface. Get one from [`Table.report()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#report), by name or ID. Getting one doesn't contact Quickbase, so a report that doesn't exist raises [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError) on first use. The examples below use `tasks`, made as shown here. Every new table has a report named `List All`. A live test runs every example with both clients and checks its output. The exception is `summary_rows()`, because a summary report can only be built in the interface. [`AsyncReport`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/) has the same methods, which you `await`, but `run()` is called without it. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### What the methods return | Type | What it is | Returned by | |---|---|---| | [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) | One page of query results. | [`run_page()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/#run_page) | | [`RecordIterator`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordIterator) | Yields records from a query, fetching further pages as needed. | [`run()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/#run) | | [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) | A report's metadata. | [`info()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/#info) | ### Run a report For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `Report.run()` Iterate over the report's records, fetching further pages as needed. Records hold only the report's columns, so `record.id` raises `KeyError` if Record ID# isn't one of them. Each run adds to the report's usage count in Quickbase. ```python run( *, page_size: int = 1000, max_total: int | None = None ) -> RecordIterator ``` **Parameters** - **`page_size`** `int`, default `1000` Records to request per call to Quickbase. - **`max_total`** `int | None`, optional Stop after this many records in all, across pages. **Returns** [`RecordIterator`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RecordIterator) An iterator of [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record), which only contacts Quickbase once you start iterating. **Raises** - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError): The table has no such report, or several share the name. - [`UnsupportedReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnsupportedReportError): It isn't a complete table report, such as a summary, chart, or ask-the-user report. Their raw results are available through `qb.request()`. Example: ```python report = tasks.report("List All") titles = [record["Title"] for record in report.run()] print(sorted(titles)) ``` Output: ```text ['Frame walls', 'Hang drywall', 'Pour slab'] ``` #### `Report.run_page()` Fetch one page of the report's records, for controlling paging yourself. Records hold only the report's columns, and each run adds to the report's usage count in Quickbase. ```python run_page(*, limit: int = 100, skip: int = 0) -> Page ``` **Parameters** - **`limit`** `int`, default `100` The most records to return. - **`skip`** `int`, default `0` How many records to pass over first. Use the previous page's `next_skip`. **Returns** [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) A [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page), whose `total` counts every record in the report. **Raises** - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError): The table has no such report, or several share the name. - [`UnsupportedReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnsupportedReportError): It isn't a complete table report, such as a summary, chart, or ask-the-user report. Example: ```python report = tasks.report("List All") page = report.run_page(limit=2) print(page.total, page.has_more, page.next_skip) ``` Output: ```text 3 True 2 ``` #### `Report.summary_rows()` Get a summary or chart report's rows, as `{column label: value}`. Quickbase does the calculating, using the report as it was built in the UI. Values are what Quickbase returns, so a chart's dates are text in the app's format, such as `01-01-2026`. Each run adds to the report's usage count in Quickbase. ```python summary_rows( *, page_size: int = 1000, max_total: int | None = None ) -> list[dict[str, Any]] ``` **Parameters** - **`page_size`** `int`, default `1000` Rows to request per call to Quickbase. - **`max_total`** `int | None`, optional Stop after this many rows in all, across pages. **Returns** `list[dict[str, Any]]` The rows. A summary report's total column is labeled like `Hours (tot)`. **Raises** - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError): The table has no such report, or several share the name. - [`UnsupportedReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnsupportedReportError): It's a table report, which [`run()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/#run) reads as records. Or Quickbase can't run it, such as a report that asks the user for a value, or two of its columns have the same label. - [`InvalidResponseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidResponseError): Quickbase stopped returning rows before the end. Example: ```python report = tasks.report("Hours by Status") for row in report.summary_rows(): print(row["Status"], row["Hours (tot)"]) ``` ### Look up the report's details For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `Report.info()` Get the report's metadata, from the table's cached list of reports. ```python info() -> ReportInfo ``` **Returns** [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) A [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo). **Raises** - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError): The table has no such report, or several share the name. An ID takes priority over a name. Example: ```python report = tasks.report("List All") info = report.info() print(info.name, info.type) ``` Output: ```text List All table ``` ### Link to the Quickbase interface For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `Report.url()` Link to the report in Quickbase, for viewing it or changing how it's built. It only contacts Quickbase to look up the report's and table's IDs, as [`info()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/report/#info) does, and the client's cache usually has them. ```python url(*, edit: bool = False) -> str ``` **Parameters** - **`edit`** `bool`, default `False` Link to the page that changes the report's settings, rather than the report. **Returns** `str` The URL, on the client's realm. **Raises** - [`UnknownReportError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownReportError): The table has no such report, or several share the name, or the client's policy hides it. - [`UnknownTableError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownTableError): The app has no such table, or the client's policy hides it. Example: ```python report = tasks.report("List All") print(report.url()) print(report.url(edit=True)) ``` Output: ```text https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/q?qid=1 https://acme.quickbase.com/nav/app/bq1234567/table/bqtasks123/action/viewBuild?qid=1 ``` ## API reference: Queries Source: Conditions choose which records a query, count, or delete applies to. Build them with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), which escapes values for you, or with [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict) for plain data from a model or a web form. The examples below use `tasks`, made as shown here, and `app`, which is `qb.app("bq1234567")`. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### Build a condition For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `F` A field in a query, named by label (`str`) or ID (`int`). `(F("Status") == "Done") & (F("Due Date") < date.today())`. Values are escaped for you, so this is the safe way to use text you don't control. `== None` matches empty fields. On a `user` or `multiuser` field, compare with an email or user ID. On a `percent` field, a number is a fraction, as it is when writing, so `F("Done") >= 0.5` means 50%. On a `duration` field, a number is milliseconds, or use a `timedelta`. Text is sent as written, such as `F("Budget") > "8 hours"`. ```python F(field: str | int) ``` **Parameters** - **`field`** `str | int`, required The field's label or ID. ##### `F.contains()` Match records whose value contains this text. ```python contains(value: Any) -> Condition ``` **Parameters** - **`value`** `Any`, required The text to look for. **Returns** `Condition` A condition to pass as `where`. ##### `F.not_contains()` Match records whose value doesn't contain this text. ```python not_contains(value: Any) -> Condition ``` **Parameters** - **`value`** `Any`, required The text to look for. **Returns** `Condition` A condition to pass as `where`. ##### `F.starts_with()` Match records whose value starts with this text. ```python starts_with(value: Any) -> Condition ``` **Parameters** - **`value`** `Any`, required The text to look for. **Returns** `Condition` A condition to pass as `where`. ##### `F.not_starts_with()` Match records whose value doesn't start with this text. ```python not_starts_with(value: Any) -> Condition ``` **Parameters** - **`value`** `Any`, required The text to look for. **Returns** `Condition` A condition to pass as `where`. ##### `F.has()` Match records where one of the field's values is exactly this, such as one choice of a multi-select field, or one user of a list-user field. ```python has(value: Any) -> Condition ``` **Parameters** - **`value`** `Any`, required The whole value to look for. Unlike `contains`, part of a value doesn't match. **Returns** `Condition` A condition to pass as `where`. ##### `F.not_has()` Match records where none of the field's values is exactly this. ```python not_has(value: Any) -> Condition ``` **Parameters** - **`value`** `Any`, required The whole value to look for. **Returns** `Condition` A condition to pass as `where`. ##### `F.is_in()` Match any of the values, as a series of `EX` conditions joined with `OR`. ```python is_in(values: Iterable[Any]) -> Expression ``` **Parameters** - **`values`** `Iterable[Any]`, required The values to match. There must be at least one. **Returns** [`Expression`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#Expression) A condition to pass as `where`. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): There are no values. ##### `F.op()` Use any Quickbase operator directly, such as `TV` or `IR`. ```python op(operator: str, value: Any) -> Condition ``` **Parameters** - **`operator`** `str`, required The operator, in capitals. Quickbase treats a lowercase one as "contains". - **`value`** `Any`, required The value to compare with. **Returns** `Condition` A condition to pass as `where`. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): Quickbase has no such operator. Example: ```python from pyquickbase import F where = (F("Status") == "Open") & (F("Hours") >= 10) for task in tasks.query(where=where, select=["Title"]): print(task["Title"]) print(tasks.count(F("Title").contains("wall"))) print(tasks.count(F("Title").starts_with("Pour"))) print(tasks.count(F("Status").is_in(["Open", "Done"]))) print(tasks.count(F("Due Date") == None)) print(tasks.count(F("Title").op("XCT", "wall"))) ``` Output: ```text Frame walls 2 1 3 1 1 ``` #### `Expression` A query condition. Combine with `&` and `|`, not `and` and `or`. Example: ```python from pyquickbase import F done_or_long = (F("Status") == "Done") | (F("Hours") > 10) print(tasks.count(done_or_long)) ``` Output: ```text 2 ``` ### Conditions from plain data For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `where_from_dict` Build a condition from plain data, such as the JSON from a model's tool call. The result works anywhere a condition built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F) does, including combined with one, which is how to keep a model inside a scope your code decides: ```python scope = F("Project") == project allowed = ["Status", "Due Date"] where = scope & where_from_dict( data, fields=allowed ) ``` A condition is `{"field": "Status", "op": "==", "value": "Open"}`, and `{"all": [...]}` and `{"any": [...]}` combine them, nested if needed. `field` is a label or ID. `op` is `==`, `!=`, `<`, `<=`, `>`, `>=`, `contains`, `not_contains`, `starts_with`, `not_starts_with`, `has`, `not_has`, `in`, `not_in`, or a Quickbase operator such as `TV`, other than `IR` and `XIR`. `has` matches one of a multi-select field's values exactly. Spellings a model tends to write are read as the operator they mean: case, spaces, hyphens, and camelCase don't matter, so `starts with` and `startsWith` are `starts_with`, and `eq`, `=`, `ne`, `<>`, `lt`, `gte`, and the like are the matching symbol. `and` and `or` are read as `all` and `any`. Each of these means exactly one operator, so none can change what a filter means. Labels are never guessed: a label must match exactly. Values are escaped as they are with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F). Unlike [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), a value is also checked against the type of its field when the query is built: dates must be `YYYY-MM-DD`, timestamps need a time zone or to be dates, and numbers and checkboxes must be what they say. So `today` is refused, as Quickbase matches nothing, with no error, for text it can't read as a date. This protects a query's shape, not its meaning. A model that chooses the field and operator chooses the result, so a condition can still match every record. See [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/untrusted-input/#filters-from-a-model). ```python where_from_dict( data: Mapping[str, Any], *, fields: ( Iterable[str | int] | Iterable[Field] | None ) = None, ) -> Expression ``` **Parameters** - **`data`** `Mapping[str, Any]`, required The condition or group. `value` is required on every condition, so that a dropped key can't turn into a search for empty fields. Send `null` to mean that on purpose, which works with `==`, `!=`, `in`, and `not_in`. - **`fields`** `Iterable[str | int] | Iterable[Field] | None`, optional The only fields this filter may use, as labels, IDs, or [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) objects, which count by label. Pass the same list as to [`where_schema`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_schema). Without this, it may use any field the token can see, and a model could learn a value it can't read by filtering on it. A field listed by label can't be named by its ID. **Returns** [`Expression`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#Expression) A condition to pass as `where`. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): The data can't be used, and the message says where. Nothing was sent. One with over 100 conditions, counting each value of `in` and `not_in`, or groups over 20 levels deep, is refused too. Type checks raise this when the query is built, not here. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The table has no such field, raised when the query is built. Example: ```python from pyquickbase import where_from_dict data = { "all": [ {"field": "Status", "op": "==", "value": "Open"}, {"field": "Hours", "op": ">", "value": 10}, ] } where = where_from_dict(data, fields=["Status", "Hours"]) print(tasks.count(where)) ``` Output: ```text 1 ``` #### `where_schema` Describe the format of [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict) as JSON Schema, for a tool's input. The schema is self-contained, with no `$ref`, so it can go anywhere in a tool's input schema. It's a new dictionary each time, so you can add to it, such as a `description`. ```python where_schema( *, fields: ( Iterable[str | int] | Iterable[Field] | None ) = None, field_descriptions: Mapping[str | int, str] | None = None, max_depth: int = 2, ) -> dict[str, Any] ``` **Parameters** - **`fields`** `Iterable[str | int] | Iterable[Field] | None`, optional The fields to offer, as labels, IDs, or [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) objects, so a model can't name one that doesn't exist. Pass the same fields to [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict), by label or ID, to enforce the list, as this only describes it. Field labels can contain any text, and this shows them to the model, so only offer fields from an app you trust. Given [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field) objects, the description also says what a number means on a percent or duration field. - **`field_descriptions`** `Mapping[str | int, str] | None`, optional What each field means, as `{label or ID: text}`, added to the schema's description so the model can choose the right field. Each must be for a field in [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields), so a renamed field can't silently keep a description. - **`max_depth`** `int`, default `2` How many levels of `all` and `any` to describe, from 0 to 5. The default allows `A and (B or C)`. A tool's schema is sent to the model with every request, so each level has a cost. [`where_from_dict`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#where_from_dict) accepts deeper filters regardless. **Returns** `dict[str, Any]` The JSON Schema, as a dictionary. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) is empty, `max_depth` is out of range, or a description isn't for a field in [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields). Example: ```python from pyquickbase import where_schema schema = where_schema(fields=["Status", "Hours"], max_depth=1) print(type(schema).__name__, "$ref" in str(schema)) ``` Output: ```text dict False ``` ### Check a hand-written condition For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `parse_where` Check a hand-written `where` string and return its conditions. Useful for extra checks of your own, or for finding out which fields a string refers to. ```python parse_where( where: str, fields: Iterable[Field] ) -> list[WhereCondition] ``` **Parameters** - **`where`** `str`, required The string, such as `{7.EX.'Open'}`. - **`fields`** `Iterable[Field]`, required The table's fields, from [`Table.fields()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#fields). **Returns** `list[` [`WhereCondition`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#WhereCondition) `]` A [`WhereCondition`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#WhereCondition) for each condition in the string. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): It has the same mistakes that `table.query()` would refuse. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): It names a field that isn't in [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields). Example: ```python from pyquickbase import parse_where fields = tasks.fields() where = "{7.EX.'Open'}AND{8.GT.'5'}" print([c.field_id for c in parse_where(where, fields)]) ``` Output: ```text [7, 8] ``` #### `WhereCondition` One condition from a `where` string, with its value unquoted and unescaped. **Attributes** - **`field_id`** `int` The ID of the field it tests. - **`operator`** `str` Quickbase's operator, such as `EX`. - **`value`** `str` The value it compares with, as text. Example: ```python from pyquickbase import parse_where fields = tasks.fields() condition = parse_where("{7.EX.'Open'}", fields)[0] print(condition.field_id, condition.operator, condition.value) ``` Output: ```text 7 EX Open ``` ### Ways to name a field For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `FieldRef` A field, by label (`str`) or ID (`int`). ```python FieldRef = str | int ``` #### `FieldRefs` One or more fields, by label, ID, or [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), as `select` and `order_by` take them. ```python FieldRefs = str | int | F | Sequence[str | int | F] ``` ## API reference: Records and results Source: These are what reads and writes give you back, and what you pass to write a file. The examples below use `tasks`, made as shown here, and `app`, which is `qb.app("bq1234567")`. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### Read results For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `Record` One record. Read values by field label or ID: `record["Status"]` or `record[7]`. Iterating yields labels, so `dict(record)` gives `{label: value}`. Date fields are `date` objects and timestamp fields are timezone-aware `datetime` objects, or None when empty. A user field holds a [`User`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#User), a `multiuser` field a list of them, and a file field a [`FileAttachment`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileAttachment), or None when empty. **Attributes** - **`id`** `int` The Record ID#. Queries always select it, but a report may not include it. Example: ```python task = tasks.get( 1, select=["Title", "Status", "Due Date"] ) print(task.id, task["Title"], task[7]) print(task["Due Date"]) print(sorted(label for label in task)) ``` Output: ```text 1 Pour slab Open 2027-01-15 ['Due Date', 'Record ID#', 'Status', 'Title'] ``` #### `RecordIterator` Yields records from a query, fetching further pages as needed. A class rather than an async generator, so stopping early with `break` needs no cleanup. ##### `RecordIterator.to_csv()` Write the remaining records to a CSV file as they're fetched, with a header row. Dates and timestamps are ISO 8601, with a timestamp's offset. A user is their email, or their ID if the client's policy hides emails, a file is its latest version's name, and a list's values are separated by `;`. `rows_from_file()` reads the file back. ```python to_csv( destination: FileLike, *, spreadsheet_safe: bool = True, encoding: str = "utf-8-sig", ) -> int ``` **Parameters** - **`destination`** `FileLike`, required A path, or a file open for writing text. - **`spreadsheet_safe`** `bool`, default `True` Write text that a spreadsheet would run as a formula, which starts with `=`, `+`, `-`, or `@`, with a leading `'`, so opening the file can't run it. `rows_from_file()` removes it again. - **`encoding`** `str`, default `'utf-8-sig'` The text encoding for a path. The default writes a byte order mark, which Excel needs to read UTF-8. **Returns** `int` How many records were written. ##### `RecordIterator.to_excel()` Write the remaining records to an Excel file, with a header row. Needs `pyquickbase[excel]`. Dates and numbers are Excel's own, and a timestamp is written in UTC, as Excel has no time zones. Text is always text, so a value starting with `=` is never a formula. Values are written as for `to_csv()`. ```python to_excel( destination: FileLike, *, sheet: str = "Records" ) -> int ``` **Parameters** - **`destination`** `FileLike`, required A path, or a file open for writing bytes. - **`sheet`** `str`, default `'Records'` The worksheet's name. **Returns** `int` How many records were written. ##### `RecordIterator.to_pandas()` Fetch the remaining records into a pandas DataFrame, with a column for each field. A user field holds the user's email, or their ID if the client's policy hides emails, and a file field holds the latest version's file name. Needs `pyquickbase[pandas]`. ```python to_pandas() -> pandas.DataFrame ``` **Returns** `pandas.DataFrame` A DataFrame, which has the columns even when nothing matched. ##### `RecordIterator.to_polars()` Fetch the remaining records into a polars DataFrame. See `to_pandas()`. Needs `pyquickbase[polars]`. ```python to_polars() -> polars.DataFrame ``` **Returns** `polars.DataFrame` A DataFrame, which has the columns even when nothing matched. Example: ```python records = tasks.query(select=["Title"], max_total=2) for task in records: print(task.id, task["Title"]) ``` Output: ```text 1 Pour slab 2 Frame walls ``` #### `Page` One page of query results. **Attributes** - **`records`** `tuple[Record, ...]` The [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record) objects on this page. - **`total`** `int` How many records match, not just the ones on this page. - **`skip`** `int` How many matching records came before this page. - **`columns`** `tuple[str, ...]` The labels of the fields it returned, in order, even when it has no records. - **`has_more`** `bool` Whether more matching records come after this page. - **`next_skip`** `int` The `skip` that fetches the following page. ##### `Page.to_csv()` Write this page's records to a CSV file, with a header row. See `RecordIterator.to_csv()`. ```python to_csv( destination: FileLike, *, spreadsheet_safe: bool = True, encoding: str = "utf-8-sig", ) -> int ``` **Parameters** - **`destination`** `FileLike`, required A path, or a file open for writing text. - **`spreadsheet_safe`** `bool`, default `True` Write text that a spreadsheet would run as a formula with a leading `'`. - **`encoding`** `str`, default `'utf-8-sig'` The text encoding for a path. **Returns** `int` How many records were written. ##### `Page.to_excel()` Write this page's records to an Excel file, with a header row. See `RecordIterator.to_excel()`. Needs `pyquickbase[excel]`. ```python to_excel( destination: FileLike, *, sheet: str = "Records" ) -> int ``` **Parameters** - **`destination`** `FileLike`, required A path, or a file open for writing bytes. - **`sheet`** `str`, default `'Records'` The worksheet's name. **Returns** `int` How many records were written. ##### `Page.to_pandas()` This page's records as a pandas DataFrame, with a column for each field returned. A user field holds the user's email, or their ID if the client's policy hides emails, and a file field holds the latest version's file name. Needs `pyquickbase[pandas]`. ```python to_pandas() -> pandas.DataFrame ``` **Returns** `pandas.DataFrame` A DataFrame, which has the columns even when the page has no records. ##### `Page.to_polars()` This page's records as a polars DataFrame. See `to_pandas()`. Needs `pyquickbase[polars]`. ```python to_polars() -> polars.DataFrame ``` **Returns** `polars.DataFrame` A DataFrame, which has the columns even when the page has no records. Example: ```python page = tasks.query_page(limit=2) print(len(page.records), page.total, page.skip) print(page.has_more, page.next_skip) ``` Output: ```text 2 3 0 True 2 ``` #### `User` A Quickbase user, as a `user` or `multiuser` field holds one. `repr` leaves out the email address, because reprs end up in logs. Pass a [`User`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#User) as a value to write it to a user field, or to compare with one in [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F). **Attributes** - **`id`** `str` The user's ID. - **`name`** `str` The user's name, as Quickbase shows it. - **`email`** `str | None` The user's email address, or None if the client's policy leaves it out. - **`user_name`** `str | None` The user's Quickbase user name, if Quickbase sent one. Example: ```python from pyquickbase import User task = tasks.get(1, select=["Record Owner"]) owner = task["Record Owner"] print(isinstance(owner, User), bool(owner.email)) ``` Output: ```text True True ``` ### Write results For the full picture, see [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/). #### `WriteResult` What a write did. If rows fail, `WriteError.result` holds this, including what succeeded. **Attributes** - **`created_ids`** `tuple[int, ...]` The Record ID# of each new record, in the order of the rows that succeeded. Use `row_ids` to match them to your rows. - **`updated_ids`** `tuple[int, ...]` The records whose values changed. - **`unchanged_ids`** `tuple[int, ...]` The records that already held these values. - **`errors`** `tuple[RowError, ...]` A [`RowError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#RowError) for each row that failed. - **`row_ids`** `tuple[int | None, ...]` For `insert()`, the new Record ID# of each row, in the order of your rows, or None for a row that failed. It stops short at a batch that wasn't sent, or whose response can't be matched to its rows, so compare its length with your rows. Empty for `update()` and `upsert()`. Example: ```python result = tasks.upsert( [ {"Code": "T-1", "Status": "Open"}, {"Code": "T-2", "Status": "Done"}, {"Code": "T-9", "Title": "Inspect site"}, ], merge_on="Code", ) print(result.created_ids) print(result.updated_ids) print(result.unchanged_ids) print(result.errors) ``` Output: ```text (4,) (2,) (1,) () ``` #### `RowError` Why one row failed. **Attributes** - **`index`** `int` The row's position in the rows you passed, starting from 0. - **`messages`** `tuple[str, ...]` Quickbase's wording, which can change, so use `reason` to decide what to do. For a `rule` failure, this holds the rule's message. - **`reason`** `Literal['duplicate', 'permission', 'invalid_choice', 'rule', 'other']` `duplicate` for a value already in a unique field, `permission` for a refusal by the token's role, `invalid_choice` for a value outside a field's choices, `rule` for a refusal by one of the table's custom data rules, and `other` for anything else, including wording this version doesn't recognize. - **`field_id`** `int | None` The field Quickbase named, for `duplicate` and `invalid_choice`. Example: ```python from pyquickbase import WriteError try: tasks.insert( [ {"Title": "New", "Code": "T-8"}, {"Title": "Repeat", "Code": "T-1"}, ] ) except WriteError as error: print(error.result.created_ids) for failure in error.result.errors: print(failure.index, failure.reason, failure.field_id) ``` Output: ```text (4,) 1 duplicate 10 ``` ### Files For the full picture, see [File attachments](https://matthew-williams-solutions.github.io/pyquickbase/guides/files/). #### `FileUpload` A file to write to a file attachment field, as a value in `insert()`, `update()`, or `upsert()`. `repr` leaves out the contents, because they may be sensitive and reprs end up in logs. ```python FileUpload(file_name: str, data: bytes) ``` **Parameters** - **`file_name`** `str`, required The file's name. It can't contain a path or control characters, because Quickbase stores them as sent and could pass them to whoever downloads the file later. - **`data`** `bytes`, required The file's contents. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): `file_name` is empty, or contains a path or control characters. Example: ```python from pyquickbase import FileUpload upload = FileUpload("spec.txt", b"Pour on Monday") result = tasks.update( [{"Record ID#": 1, "Attachment": upload}] ) print(result.updated_ids) ``` Output: ```text (1,) ``` #### `FileDownload` A file downloaded from a file attachment field. `repr` leaves out the contents, because they may be sensitive and reprs end up in logs. **Attributes** - **`file_name`** `str` The file's name. Quickbase stores names as sent, such as `../../x`, so don't use this as a path without cleaning it. - **`data`** `bytes` The file's contents. - **`content_type`** `str | None` The media type, if reported. It's based on the name rather than the contents, so it doesn't tell you what's in the file. Example: ```python from pyquickbase import FileUpload upload = FileUpload("spec.txt", b"Pour on Monday") tasks.update([{"Record ID#": 1, "Attachment": upload}]) download = tasks.download_file(1, "Attachment") print(download.file_name, download.data) ``` Output: ```text spec.txt b'Pour on Monday' ``` #### `FileAttachment` A file attachment field's value, which holds each version Quickbase keeps. An empty field reads as None, so a [`FileAttachment`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileAttachment) always has at least one version. Download the contents with `table.download_file()`. **Attributes** - **`url`** `str` The file's path in Quickbase, relative to the realm. - **`versions`** `tuple[FileVersion, ...]` Each version Quickbase keeps, oldest first. - **`latest`** `FileVersion` The newest version. Example: ```python from pyquickbase import FileUpload upload = FileUpload("plan.txt", b"v1") tasks.update([{"Record ID#": 2, "Attachment": upload}]) task = tasks.get(2, select=["Attachment"]) attachment = task["Attachment"] print(attachment.latest.file_name, len(attachment.versions)) ``` Output: ```text plan.txt 1 ``` #### `FileVersion` One version of a file in a file attachment field. **Attributes** - **`number`** `int` The version's number, starting at 1. Numbers aren't reused after a delete. - **`file_name`** `str` The file's name. Quickbase stores names as sent, such as `../../x`, so don't use this as a path without cleaning it. - **`uploaded`** `datetime.datetime | None` When it was uploaded, or None if Quickbase didn't say. - **`creator`** `User | None` Who uploaded it, or None if the client's policy leaves it out. Example: ```python from pyquickbase import FileUpload upload = FileUpload("notes.txt", b"v1") tasks.update([{"Record ID#": 3, "Attachment": upload}]) task = tasks.get(3, select=["Attachment"]) latest = task["Attachment"].latest print(latest.number, latest.file_name) ``` Output: ```text 1 notes.txt ``` ### CSV and Excel files For the full picture, see [CSV and Excel files](https://matthew-williams-solutions.github.io/pyquickbase/guides/spreadsheets/). #### `FileRows` A file's rows, converted for a table's fields, from [`Table.rows_from_file()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#rows_from_file). Pass it to `insert()`, `update()`, `upsert()`, or `preview_update()`, which refuse it, writing nothing, while it has errors. Iterating it gives its rows. `repr` shows only its size, because values may be sensitive and reprs end up in logs. **Attributes** - **`rows`** `tuple[Mapping[str, Any], ...]` Each row that has a value, as `{field label: value}`. A blank cell is None, which leaves a field empty in `insert()`, and clears it in `update()` and `upsert()`. - **`row_numbers`** `tuple[int, ...]` Each row's number in the file, as for `FileRowError.row`, so `rows[0]` came from row `row_numbers[0]`. A row whose cells are all blank is left out. - **`columns`** `Mapping[str, str]` Which field each column was read into, as `{column: field label}`. - **`skipped`** `tuple[str, ...]` The columns that weren't read. - **`errors`** `tuple[FileRowError, ...]` Every value that can't be written. Fix these in the file, or map or skip their columns. - **`ok`** `bool` Whether every value can be written. ##### `FileRows.raise_for_errors()` Raise [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError) listing the first errors, if there are any. ```python raise_for_errors() -> None ``` **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): The file has values that can't be written. Example: ```python import io text = io.StringIO('Title,Hours\nA,"1,234.5"\nB,lots\n') rows = tasks.rows_from_file(text) print(rows.rows[0]["Hours"], rows.ok) print(rows.columns) ``` Output: ```text 1234.5 False {'Title': 'Title', 'Hours': 'Hours'} ``` #### `FileRowError` A value in a file that can't be written to its field. **Attributes** - **`row`** `int` The row's number as a spreadsheet shows it, counting the header as row 1. - **`column`** `str` The file's column. - **`message`** `str` What's wrong, which never repeats the value. Example: ```python import io text = io.StringIO("Title,Due Date\nA,next week\n") rows = tasks.rows_from_file(text) for error in rows.errors: print(error.row, error.column) print(error.message) ``` Output: ```text 2 Due Date isn't a date in the form YYYY-MM-DD. Set date_format for another. ``` #### `FileFormat` A file's format: `csv`, including tab-separated files, or `xlsx`. ```python FileFormat = Literal["csv", "xlsx"] ``` #### `FileLike` A path, or an open file: text for CSV, and binary for Excel. ```python FileLike = str | os.PathLike[str] | IO[str] | IO[bytes] ``` #### `ColumnMap` Which field each file column goes in, by label or ID, or None to skip it. ```python ColumnMap = Mapping[str, str | int | None] ``` ### Formula results For the full picture, see [Formulas](https://matthew-williams-solutions.github.io/pyquickbase/guides/formulas/). #### `FormulaResult` The result of `table.run_formula()`. **Attributes** - **`text`** `str` Exactly what Quickbase sent, which is always a string. - **`type`** `str` Quickbase's name for its type, such as `number`, `bool`, or `date`. - **`value`** `Any` The result as a Python value, converted only where that's safe. A `bool` becomes `True` or `False`, and a `number` becomes a `Decimal`, or None if either is empty. A `null` becomes None. Anything else stays as text, including dates and timestamps, because they use the app's display format and have no time zone. Example: ```python result = tasks.run_formula("1 + 1") print(result.type, result.text, result.value) ``` Output: ```text number 2 2 ``` ### Previews of writes For the full picture, see [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/). #### `UpdatePreview` What `update()` would do if the records stayed as they are now. **Attributes** - **`changes`** `tuple[RecordChange, ...]` The records that would change, in the order of your rows. - **`unchanged_ids`** `tuple[int, ...]` The records that already hold these values. - **`missing_ids`** `tuple[int, ...]` The records that don't exist, or that the token can't see. `update()` would report these as failed rows. - **`events`** `tuple[AppEvent, ...] | None` The active webhooks, Quickbase actions, email notifications, and automations on the table, which the update may set off. Quickbase doesn't say which changes set each one off, so some may not run. None if they couldn't be read. Example: ```python preview = tasks.preview_update( [ {"Record ID#": 1, "Status": "Done"}, {"Record ID#": 2, "Status": "Open"}, {"Record ID#": 999, "Status": "Done"}, ] ) print(len(preview.changes)) print(preview.unchanged_ids, preview.missing_ids) ``` Output: ```text 1 (2,) (999,) ``` #### `DeletePreview` What `delete()` would delete: a page of the matching records, and what may run. **Attributes** - **`records`** `tuple[Record, ...]` The first records that would be deleted. - **`total`** `int` How many records would be deleted. - **`skip`** `int` How many matching records came before this page. - **`columns`** `tuple[str, ...]` The labels of the fields it returned, in order. - **`events`** `tuple[AppEvent, ...] | None` The active webhooks, Quickbase actions, email notifications, and automations on the table, which deleting records may set off. Quickbase doesn't say which changes set each one off, so some may not run. None if they couldn't be read. Example: ```python from pyquickbase import F preview = tasks.preview_delete(F("Status") == "Done") print(preview.total, preview.events) ``` Output: ```text 1 () ``` #### `RecordChange` What a write would change on one record. **Attributes** - **`record_id`** `int` The record's Record ID#. - **`fields`** `Mapping[str, FieldChange]` What would change, as `{field label: FieldChange}`. Example: ```python preview = tasks.preview_update( [{"Record ID#": 1, "Status": "Done"}] ) change = preview.changes[0] print(change.record_id, sorted(change.fields)) ``` Output: ```text 1 ['Status'] ``` #### `FieldChange` One field's value before and after a write, with an empty field as None. `repr` hides both values, because they may be sensitive and reprs end up in logs. **Attributes** - **`before`** `Any` The value now. A file field shows the file's name, never its contents. - **`after`** `Any` The value the write would set. A file field shows the file's name here too. Example: ```python preview = tasks.preview_update( [{"Record ID#": 1, "Status": "Done"}] ) status = preview.changes[0].fields["Status"] print(status.before, status.after) ``` Output: ```text Open Done ``` ### Summaries For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `SummaryRow` One group of records, from `table.summarize()`. Values are keyed by field label. `repr` shows only the row's shape, because values may be sensitive and reprs end up in logs. **Attributes** - **`group`** `Mapping[str, Any]` The values that define this group, or empty without `group_by`. An empty value is None, and a user is their email. - **`count`** `int` How many records are in the group, including those with empty values. - **`sum`** `Mapping[str, float]` The sum of each `sum` field, which is 0 if every value is empty. - **`avg`** `Mapping[str, float | None]` The average of each `avg` field, or None if every value is empty. - **`median`** `Mapping[str, float | None]` The middle value of each `median` field, or the average of the two middle ones, or None if every value is empty. - **`std_dev`** `Mapping[str, float | None]` The sample standard deviation of each `std_dev` field, or None with fewer than two values. - **`min`** `Mapping[str, Any]` The lowest value of each `min` field, or None if every value is empty. - **`max`** `Mapping[str, Any]` The highest value of each `max` field, or None if every value is empty. - **`distinct_count`** `Mapping[str, int]` How many different values each `distinct_count` field has, leaving out empty ones. - **`change`** `PeriodChange | None` With `compare_periods`, how the group changed since the period before. None for the first period, and without `compare_periods`. ##### `SummaryRow.statistic()` One statistic's values, by field label, such as `row.statistic("sum")`. ```python statistic(name: StatisticName) -> Mapping[str, Any] ``` **Parameters** - **`name`** `StatisticName`, required The statistic, such as `sum` or `median`. **Returns** `Mapping[str, Any]` Its values, by field label, which is empty if the summary didn't ask for it. Example: ```python rows = tasks.summarize( "Status", sum="Hours", median="Hours" ) for row in rows: print(row.group["Status"], row.count) print(row.sum["Hours"], row.median["Hours"]) ``` Output: ```text Done 1 6.0 6.0 Open 2 20.0 10.0 ``` #### `PeriodChange` How a group changed since the period before it, from `summarize(compare_periods=True)`. **Attributes** - **`previous`** `str` The period it's compared with, such as `2026-02` for `2026-03`. - **`count`** `int` How many more records there are, which is negative for fewer. - **`values`** `Mapping[str, Mapping[str, float | None]]` How much each numeric statistic rose, as `{"sum": {"Hours": 4.0}}`, or None where either period has no value. A period without records counts as 0 for `sum` and `distinct_count`, and has no value for the others. Example: ```python from pyquickbase import Period rows = tasks.summarize( Period("Due Date", "month"), sum="Hours", compare_periods=True, ) for row in rows: if row.change: print(row.group["Due Date"], row.change.previous) print(row.change.count, row.change.values["sum"]) ``` Output: ```text 2027-02 2027-01 0 {'Hours': 4.0} ``` #### `Period` Group by a date or timestamp field's day, week, month, quarter, or year. Groups are labeled `2026-03-14`, `2026-W11` (an ISO week), `2026-03`, `2026-Q1`, and `2026`, so they sort in order. ```python Period( field: str | int | F, unit: PeriodUnit, tz: datetime.tzinfo = datetime.timezone.utc, ) ``` **Parameters** - **`field`** `str | int | F`, required The date or timestamp field, by label, ID, or field object. - **`unit`** `PeriodUnit`, required `day`, `week`, `month`, `quarter`, or `year`. - **`tz`** `datetime.tzinfo`, default `datetime.timezone.utc` The time zone that decides a timestamp's date. It has no effect on a date field. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `unit` isn't one of these, or `field` isn't a field. Example: ```python from pyquickbase import Period rows = tasks.summarize(Period("Due Date", "month")) for row in rows: print(row.group["Due Date"], row.count) ``` Output: ```text 2027-01 1 2027-02 1 None 1 ``` #### `PeriodUnit` How much time each group of a [`Period`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Period) covers. ```python PeriodUnit = Literal[ "day", "week", "month", "quarter", "year" ] ``` #### `StatisticName` A value `summarize()` works out from one field's values in each group. ```python StatisticName = Literal[ "sum", "avg", "median", "std_dev", "min", "max", "distinct_count", ] ``` #### `SummaryGroups` What [`Table.summarize()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#summarize) can group by. ```python SummaryGroups = ( str | int | F | Period | Sequence[str | int | F | Period] ) ``` ## API reference: Metadata Source: These describe an app and what's in it. The client caches them for five minutes by default, and each holds everything Quickbase returned in `raw`. The examples below use `tasks`, made as shown here, and `app`, which is `qb.app("bq1234567")`. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. `projects` is a table named `Job Projects`, with a `Name` field. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### Apps For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `AppInfo` An app's metadata. **Attributes** - **`id`** `str` The app's ID. - **`name`** `str` The app's name. - **`description`** `str` The app's description, which may be empty. - **`time_zone`** `str | None` The app's time zone, such as `(UTC) Coordinated Universal Time`, if reported. - **`date_format`** `str | None` How the app writes dates, such as `MM-DD-YYYY`, if reported. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. Example: ```python info = app.info() print(info.id) ``` Output: ```text bq1234567 ``` #### `Role` A role in an app. **Attributes** - **`id`** `int` The role's ID. - **`name`** `str` The role's name. - **`access`** `str` Quickbase's name for its level, such as `Basic Access`. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. Example: ```python for role in app.roles(): if role.name == "Administrator": print(role.name, role.access) ``` Output: ```text Administrator Administrator ``` #### `AppEvent` Something in an app that runs when records change or on a schedule, such as a webhook. Quickbase lists webhooks, Quickbase actions, email notifications, subscriptions, reminders, and automations that records trigger. It leaves out scheduled automations, and doesn't say which changes set each one off. **Attributes** - **`type`** `str` `webhook`, `qb-action`, `email-notification`, `subscription`, `reminder`, or `automation`. - **`name`** `str | None` Its name. Quickbase doesn't name automations, so it's None for those. - **`active`** `bool` Whether it's switched on. - **`table_id`** `str | None` The table it watches, if Quickbase said. - **`url`** `str | None` For an automation, a link to it in Quickbase. - **`owner`** `User | None` Who owns it, or None if the client's policy leaves it out. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. - **`runs_on_changes`** `bool` Whether a change to records can set it off. Subscriptions and reminders run on a schedule instead. Example: ```python for event in tasks.events(): if event.active and event.runs_on_changes: print(event.type, event.name or event.url) ``` ### Tables and reports For the full picture, see [Reading records](https://matthew-williams-solutions.github.io/pyquickbase/guides/reading/). #### `TableInfo` A table's metadata. **Attributes** - **`id`** `str` The table's ID. - **`name`** `str` The table's name. - **`description`** `str` The table's description, which may be empty. - **`key_field_id`** `int | None` The ID of the table's key field, which is 3, `Record ID#`, unless someone changed it in the Quickbase interface. None if Quickbase left it out. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. Example: ```python info = tasks.info() print(info.name, info.key_field_id) ``` Output: ```text Job Tasks 3 ``` #### `ReportInfo` A report's metadata. **Attributes** - **`id`** `str` The report's ID. - **`name`** `str` The report's name. - **`type`** `str` `table`, `summary`, `chart`, or another Quickbase type. - **`description`** `str` The report's description, which may be empty. - **`incomplete`** `bool` Whether Quickbase can't run the report through the API, as with one that asks the user for a value. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. Example: ```python reports = tasks.reports() first = reports[0] print(first.name, first.type, first.incomplete) ``` Output: ```text List All table False ``` ### Fields and relationships For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `Field` A field's metadata. **Attributes** - **`id`** `int` The field's ID. - **`label`** `str` The field's label. - **`type`** `str` Quickbase's name for the field's type, such as `text` or `numeric`. - **`mode`** `str | None` `formula`, `lookup`, `summary`, or None for a plain field. - **`required`** `bool` Whether the field must have a value. - **`unique`** `bool` Whether values must be different on every record. - **`appears_by_default`** `bool` Whether a query returns the field when `select` is left out. - **`choices`** `tuple[str, ...]` The options of a multiple-choice field, or empty for other fields. - **`allow_new_choices`** `bool` Quickbase's `allowNewChoices` setting, or False if it isn't reported. - **`formula`** `str | None` The formula's text, or None if the field doesn't have one. - **`help_text`** `str` The help Quickbase shows beside the field on forms, which may be empty. - **`decimal_places`** `int | None` Only how a number is displayed: Quickbase still stores `1.5` in a field set to 0 places. - **`is_foreign_key`** `bool` Whether the field links to a parent table's record. - **`lookup_reference_field_id`** `int | None` For a lookup, the foreign key field on this table. - **`lookup_target_field_id`** `int | None` For a lookup, the field it reads on the parent. - **`summary_reference_field_id`** `int | None` For a summary, the foreign key field on the child table. - **`summary_target_field_id`** `int | None` For a summary, the field it reads on the child. - **`summary_function`** `str | None` For a summary, Quickbase's name for its calculation, such as `SUM`. - **`summary_query`** `str | None` For a summary, the filter on which child records it combines, such as `{7.EX.true}`, or None for all of them. On a client with a policy it's None unless the client can read every field the filter uses on the child table. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. Example: ```python for field in tasks.fields(): if field.label == "Status": print(field.id, field.type, field.mode) print(sorted(field.choices), field.required) ``` Output: ```text 7 text-multiple-choice None ['Done', 'Open'] False ``` #### `FieldUsage` Where a field is used in its app, as counted on the field's Usage page in Quickbase. Quickbase counts a field in the reports it makes for every table, so most fields have a few uses. Counts can take a few seconds to include a change. **Attributes** - **`field_id`** `int` The field's ID. - **`label`** `str` The field's label. - **`counts`** `Mapping[str, int]` How many of each kind of thing use it, such as `{"reports": 2, "forms": 1}`, leaving out the kinds that don't. A kind Quickbase adds later is included too. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. - **`total`** `int` How many things use the field in all. ##### `FieldUsage.describe()` The uses in words, most first, such as `2 reports and 1 form`, or `nothing`. ```python describe() -> str ``` **Returns** `str` Example: ```python [usage] = tasks.field_usage("Title") print(usage.field_id, usage.label) print(usage.counts["default_reports"], usage.total > 0) print("report" in usage.describe()) ``` Output: ```text 6 Title 1 True True ``` #### `Relationship` A parent-child link between two tables. `create_relationship()` and `ensure_lookup_field()` take the parent's field to look up, which isn't what `lookup_field_ids` holds. **Attributes** - **`id`** `int` The relationship's ID. - **`parent_table_id`** `str` The parent table's ID. - **`child_table_id`** `str` The child table's ID. - **`foreign_key_field_id`** `int | None` The child's field that holds the parent's Record ID#, if reported. - **`lookup_field_ids`** `tuple[int, ...]` The lookup fields, which are on the child. - **`summary_field_ids`** `tuple[int, ...]` The summary fields, which are on the parent. - **`is_cross_app`** `bool` Whether Quickbase reports the parent as being in another app. - **`raw`** `Mapping[str, Any]` Everything Quickbase returned, for anything not listed here. Example: ```python relationship = tasks.ensure_relationship( projects, foreign_key="Project" ) print(relationship.foreign_key_field_id) print( relationship.lookup_field_ids, relationship.is_cross_app ) ``` Output: ```text 12 () False ``` #### `TableSchema` A table's fields and the relationships in which it's the child, as the client can see them. **Attributes** - **`info`** `TableInfo` The table's details. - **`fields`** `tuple[Field, ...]` Its fields. - **`relationships`** `tuple[Relationship, ...]` The relationships in which it's the child. Example: ```python describe = app.operation("describe_schema") (schema,) = describe.run({"tables": ["Job Tasks"]}) print(schema.info.name, len(schema.fields)) ``` Output: ```text Job Tasks 11 ``` ## API reference: Schema changes Source: Describe changes to an app's tables, fields, and relationships, then pass them to [`App.preview_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#preview_schema_changes), which says what each would do without making it, and [`App.apply_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#apply_schema_changes), which makes them if the app hasn't changed since. A change the app already has counts as done. The examples below use `tasks`, made as shown here, and `app`, which is `qb.app("bq1234567")`. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. `projects` is a table named `Job Projects`, with a `Name` field. The examples only plan, unless they say otherwise. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### Tables For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `CreateTable` Make a table. A plan treats a table with this name as already made. ```python CreateTable( *, name: str, description: str | None = None, single_record_name: str | None = None, plural_record_name: str | None = None, ) ``` **Parameters** - **`name`** `str`, required The table's name. - **`description`** `str | None`, optional What it holds. - **`single_record_name`** `str | None`, optional What it calls one record, such as `Project`. - **`plural_record_name`** `str | None`, optional What it calls several, such as `Projects`. Example: ```python from pyquickbase import CreateTable plan = app.preview_schema_changes( [CreateTable(name="Job Sites", single_record_name="Site")] ) print(plan.changes[0].summary) ``` Output: ```text Create table Job Sites. ``` #### `UpdateTable` Rename a table, or change its descriptions. Anything left as None stays as it is. ```python UpdateTable( *, table: str, name: str | None = None, description: str | None = None, single_record_name: str | None = None, plural_record_name: str | None = None, ) ``` **Parameters** - **`table`** `str`, required The table, by name or ID. - **`name`** `str | None`, optional A new name. - **`description`** `str | None`, optional A new description. - **`single_record_name`** `str | None`, optional A new name for one record. - **`plural_record_name`** `str | None`, optional A new name for several. Example: ```python from pyquickbase import UpdateTable change = UpdateTable(table="Job Tasks", description="Work.") plan = app.preview_schema_changes([change]) print(plan.changes[0].settings["description"].after) ``` Output: ```text Work. ``` #### `DeleteTable` Delete a table and all its records. ```python DeleteTable(*, table: str) ``` **Parameters** - **`table`** `str`, required The table, by name or ID. Example: ```python from pyquickbase import DeleteTable plan = app.preview_schema_changes( [DeleteTable(table="Job Tasks")] ) change = plan.changes[0] print(change.risk, change.affected_records) ``` Output: ```text destructive 3 ``` ### Fields For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `CreateField` Add a field, or a formula field when `formula` is set. A plan treats a field with this label, type, and formula as already made. ```python CreateField( *, table: str, label: str, type: str, choices: tuple[str, ...] | None = None, required: bool | None = None, unique: bool | None = None, help_text: str | None = None, formula: str | None = None, ) ``` **Parameters** - **`table`** `str`, required The table, by name or ID. - **`label`** `str`, required The new field's label. - **`type`** `str`, required Its type, such as `text` or `numeric`. For a formula, the type of its result. - **`choices`** `tuple[str, ...] | None`, optional The options of a `text-multiple-choice` or `multitext` field. - **`required`** `bool | None`, optional Whether it must have a value. Quickbase refuses this on a table with records. - **`unique`** `bool | None`, optional Whether each record must have a different value. - **`help_text`** `str | None`, optional The help shown beside it on forms. - **`formula`** `str | None`, optional Makes it a formula field, calculated from the record's other fields. Example: ```python from pyquickbase import CreateField plan = app.preview_schema_changes( [ CreateField( table="Job Tasks", label="Cost", type="numeric", formula="[Hours] * 40", ) ] ) print(plan.changes[0].summary) ``` Output: ```text Add formula field "Cost" (numeric) to Job Tasks. ``` #### `UpdateField` Change a field's settings. Anything left as None stays as it is. Its type can't change. ```python UpdateField( *, table: str, field: str | int, label: str | None = None, choices: tuple[str, ...] | None = None, required: bool | None = None, unique: bool | None = None, help_text: str | None = None, formula: str | None = None, ) ``` **Parameters** - **`table`** `str`, required The table, by name or ID. - **`field`** `str | int`, required The field, by label or ID. - **`label`** `str | None`, optional A new label. - **`choices`** `tuple[str, ...] | None`, optional A new list of options, replacing the old one. - **`required`** `bool | None`, optional Whether it must have a value. - **`unique`** `bool | None`, optional Whether each record must have a different value. - **`help_text`** `str | None`, optional New help text. - **`formula`** `str | None`, optional A new formula, for a formula field only. Example: ```python from pyquickbase import UpdateField plan = app.preview_schema_changes( [ UpdateField( table="Job Tasks", field="Status", choices=("Open", "Done", "Blocked"), ) ] ) choices = plan.changes[0].settings["choices"] print(choices.before, choices.after) ``` Output: ```text ['Open', 'Done'] ['Open', 'Done', 'Blocked'] ``` #### `DeleteField` Delete a field and all its values. ```python DeleteField(*, table: str, field: str | int) ``` **Parameters** - **`table`** `str`, required The table, by name or ID. - **`field`** `str | int`, required The field, by label or ID. Example: ```python from pyquickbase import DeleteField plan = app.preview_schema_changes( [DeleteField(table="Job Tasks", field="Hours")] ) print(plan.changes[0].warnings[0]) ``` Output: ```text 3 records lose a value in it. ``` ### Relationships For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `CreateRelationship` Link a child table to a parent, with a new field on the child that holds the parent's Record ID#. A plan treats a link with this foreign key as already made. ```python CreateRelationship( *, child_table: str, parent_table: str, foreign_key: str ) ``` **Parameters** - **`child_table`** `str`, required The child table, by name or ID, such as `Tasks`. - **`parent_table`** `str`, required The parent table, such as `Projects`. - **`foreign_key`** `str`, required The label of the new field on the child, such as `Project`. Example: ```python from pyquickbase import CreateRelationship plan = app.preview_schema_changes( [ CreateRelationship( child_table="Job Tasks", parent_table="Job Projects", foreign_key="Project", ) ] ) print(plan.changes[0].summary) ``` Output: ```text Link Job Tasks to Job Projects through a new field "Project" on Job Tasks. ``` #### `CreateLookupField` Show a parent's field on each child record. ```python CreateLookupField( *, child_table: str, foreign_key: str, parent_field: str | int, label: str | None = None, ) ``` **Parameters** - **`child_table`** `str`, required The child table, by name or ID. - **`foreign_key`** `str`, required The label of the relationship's foreign key on the child. - **`parent_field`** `str | int`, required The parent's field to show, by label or ID. - **`label`** `str | None`, optional The lookup's label. Quickbase names one ` - ` without it. Example: ```python from pyquickbase import ( CreateLookupField, CreateRelationship, ) plan = app.preview_schema_changes( [ CreateRelationship( child_table="Job Tasks", parent_table="Job Projects", foreign_key="Project", ), CreateLookupField( child_table="Job Tasks", foreign_key="Project", parent_field="Name", ), ] ) print(plan.changes[1].summary) ``` Output: ```text Show "Name" from Job Projects on each Job Tasks record, as "Project - Name". ``` #### `CreateSummaryField` Total, average, or count a child's records on each parent record. ```python CreateSummaryField( *, child_table: str, foreign_key: str, label: str, function: SummaryFunction = "sum", child_field: str | int | None = None, where: str | None = None, ) ``` **Parameters** - **`child_table`** `str`, required The child table, by name or ID. - **`foreign_key`** `str`, required The label of the relationship's foreign key on the child. - **`label`** `str`, required The label of the new field on the parent. - **`function`** `SummaryFunction`, default `'sum'` How to combine the values, such as `sum`. `count` counts the child records, and takes no `child_field`. - **`child_field`** `str | int | None`, optional The child's field to combine, by label or ID. - **`where`** `str | None`, optional Only combine the child records that match, in Quickbase's query language with field IDs, such as `{7.EX.true}`. The plan checks it against the child's fields, as Quickbase saves one it can't use and the summary is then always empty. Example: ```python from pyquickbase import ( CreateRelationship, CreateSummaryField, ) plan = app.preview_schema_changes( [ CreateRelationship( child_table="Job Tasks", parent_table="Job Projects", foreign_key="Project", ), CreateSummaryField( child_table="Job Tasks", foreign_key="Project", label="Total Hours", child_field="Hours", ), ] ) print(plan.changes[1].summary) ``` Output: ```text Add "Total Hours" to Job Projects: the sum of "Hours" on its Job Tasks records. ``` #### `DeleteRelationship` Unlink a child table from its parent. Quickbase also deletes the relationship's lookups and summaries, and keeps the foreign key field, with its values, as a plain number. ```python DeleteRelationship( *, child_table: str, foreign_key: str ) ``` **Parameters** - **`child_table`** `str`, required The child table, by name or ID. - **`foreign_key`** `str`, required The label of the relationship's foreign key on the child. Example: ```python from pyquickbase import DeleteRelationship change = DeleteRelationship( child_table="Job Tasks", foreign_key="Project" ) plan = app.preview_schema_changes([change]) print(plan.changes[0].summary) ``` Output: ```text Unlink Job Tasks from Job Projects. ``` #### `SummaryFunction` How [`CreateSummaryField`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#CreateSummaryField) combines a child's values. `count` counts the child records. ```python SummaryFunction = Literal[ "sum", "avg", "max", "min", "std_dev", "count", "distinct_count", "combined_text", "combined_user", ] ``` ### Plans and results For the full picture, see [Changing the schema](https://matthew-williams-solutions.github.io/pyquickbase/guides/schema/). #### `SchemaPlan` What a list of changes would do to an app, checked against it as it is now. Pass it to [`App.apply_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#apply_schema_changes), which refuses it if the app has changed since. **Attributes** - **`app_id`** `str` The app it's for. - **`changes`** `tuple[PlannedChange, ...]` What each change would do, in order. - **`fingerprint`** `str` A digest of everything the plan was checked against. - **`confirmation`** `str` From a `preview_schema_changes` operation, what its `apply_schema_changes` operation needs. Empty otherwise. - **`blockers`** `tuple[str, ...]` Every reason the plan can't be applied, from all its changes. - **`warnings`** `tuple[str, ...]` Everything its changes would also do or affect. - **`has_changes`** `bool` Whether applying it would change anything. Example: ```python from pyquickbase import CreateField change = CreateField( table="Job Tasks", label="Code", type="date" ) plan = app.preview_schema_changes([change]) print(plan.has_changes) print(plan.blockers[0]) ``` Output: ```text False Job Tasks already has a field labeled "Code" (text), so it can't be added. Choose another label, or change that field. ``` #### `PlannedChange` What one change would do, checked against the app as it is now. **Attributes** - **`change`** `SchemaChange` The change asked for. - **`status`** `Literal['ready', 'already_done', 'blocked']` `ready` if it would change the app, `already_done` if the app already has it, and `blocked` if it can't be applied, which stops the whole plan. - **`risk`** `Risk` `additive`, `modifying`, or `destructive`. - **`summary`** `str` What it does, in a sentence, such as `Add field "Due" (date) to Tasks.` - **`settings`** `Mapping[str, SettingChange]` What it would set, as `{setting: SettingChange}`. - **`warnings`** `tuple[str, ...]` What it would also do or affect, such as formulas that use a deleted field. - **`blockers`** `tuple[str, ...]` Why it can't be applied. - **`affected_records`** `int | None` How many records it would affect, such as those that would lose a deleted field's values, if counted. - **`link`** `str | None` A Quickbase page to check it on, if there is one yet. - **`policy_effects`** `tuple[str, ...]` What the client's policy would do once the change is made: who can read and write a new field, and its classes, and which rules lose a deleted field or table. A rule it would leave matching nothing is also a blocker. Example: ```python from pyquickbase import UpdateField change = UpdateField( table="Job Tasks", field="Code", unique=True ) plan = app.preview_schema_changes([change]) planned = plan.changes[0] print(planned.status, planned.risk) ``` Output: ```text already_done modifying ``` #### `SettingChange` One setting before and after a change, with a setting that isn't there as None. **Attributes** - **`before`** `Any` The setting now. - **`after`** `Any` What the change would set. Example: ```python from pyquickbase import UpdateField plan = app.preview_schema_changes( [UpdateField(table="Job Tasks", field=6, label="Task")] ) setting = plan.changes[0].settings["label"] print(setting.before, "->", setting.after) ``` Output: ```text Title -> Task ``` #### `SchemaChangeResult` What applying a plan did. **Attributes** - **`applied`** `tuple[PlannedChange, ...]` The changes made, in order. - **`already_done`** `tuple[PlannedChange, ...]` The changes the app already had. - **`failed`** `PlannedChange | None` The change that failed, if one did. The ones after it weren't attempted. - **`not_attempted`** `tuple[PlannedChange, ...]` The changes after one that failed. - **`error`** `str | None` Why it failed, if it did. Example: ```python from pyquickbase import CreateField, UpdateField plan = app.preview_schema_changes( [ CreateField( table="Job Tasks", label="Crew", type="text" ), UpdateField( table="Job Tasks", field="Code", unique=True ), ] ) result = app.apply_schema_changes(plan) print(len(result.applied), len(result.already_done)) ``` Output: ```text 1 1 ``` #### `Risk` How much a planned change can lose: `additive` only adds, `modifying` changes what's there, and `destructive` deletes data or fields. ```python Risk = Literal["additive", "modifying", "destructive"] ``` ### Any change For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `SchemaChange` Any one of the changes on this page, as [`App.preview_schema_changes()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#preview_schema_changes) takes them. ```python SchemaChange = ( CreateTable | UpdateTable | DeleteTable | CreateField | UpdateField | DeleteField | CreateRelationship | CreateLookupField | CreateSummaryField | DeleteRelationship ) ``` #### `SchemaAction` The `action` of a change in the JSON form a model sends, such as `create_field`. ```python SchemaAction = Literal[ "create_table", "update_table", "delete_table", "create_field", "update_field", "delete_field", "create_relationship", "create_lookup_field", "create_summary_field", "delete_relationship", ] ``` ## API reference: SchemaOperation Source: A [`SchemaOperation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/) is one schema operation offered to a model as a tool: `describe_schema`, `preview_schema_changes`, or `apply_schema_changes`. Get one from [`App.operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#operation). `apply_schema_changes` only runs with the confirmation `preview_schema_changes` returned for the same changes, while the schema is as it was. The examples below use `plan`, made as shown here. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: app = qb.app("bq1234567") plan = app.operation("preview_schema_changes") ``` ### What the methods return | Type | What it is | Returned by | |---|---|---| | [`SchemaOperationName`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#SchemaOperationName) | | [`name`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-operation/#name) | ### Describe it to a model For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `SchemaOperation.schema` The JSON Schema of the arguments a model may send, for a tool's input. It's self-contained, with no `$ref`, and a new dictionary each time, so you can add to it. ```python schema: dict[str, Any] ``` Example: ```python changes = plan.schema["properties"]["changes"] kinds = [ item["properties"]["action"]["const"] for item in changes["items"]["oneOf"] ] print(kinds[:3]) ``` Output: ```text ['create_table', 'update_table', 'delete_table'] ``` #### `SchemaOperation.name` What the operation does, such as `preview_schema_changes`. ```python name: SchemaOperationName ``` Example: ```python print(plan.name) ``` Output: ```text preview_schema_changes ``` ### Run it For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `SchemaOperation.run()` Check a model's arguments, and run the operation. ```python run(arguments: Mapping[str, Any]) -> Any ``` **Parameters** - **`arguments`** `Mapping[str, Any]`, required The arguments from the model's tool call. **Returns** `Any` For `describe_schema`, a list of [`TableSchema`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableSchema). For `preview_schema_changes`, a [`SchemaPlan`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaPlan), whose `confirmation` `apply_schema_changes` needs, unless the plan has blockers or changes nothing. For `apply_schema_changes`, a [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult). [`tool_result`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#tool_result) takes each. **Raises** - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): An argument can't be used. Nothing was sent. - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): `apply_schema_changes` has no confirmation, or one that doesn't match these changes and the schema as it is now. Nothing was changed. - [`SchemaChangeError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#SchemaChangeError): A change failed while applying. Its `result` says which were made. Example: ```python from pyquickbase import tool_result change = { "action": "create_field", "table": "Job Tasks", "label": "Crew", "type": "text", } result = tool_result(plan.run({"changes": [change]})) print(result["changes"][0]["summary"]) print("confirmation" in result) ``` Output: ```text Add field "Crew" (text) to Job Tasks. True ``` ## API reference: Policies Source: A [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy) says what a client may read and change. Bind it to an app, which pins every table and field by ID, and pass the [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) to [`Quickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) as `policy`. It protects against bugs in your code and mistakes by a model, so give your token access to as little as possible as well, as Quickbase's own permissions are still the real boundary. Each example makes its own client from the environment, with `bqtasks123` standing for the ID of the `Job Tasks` table. A live test runs every example with both clients and checks its output. ### Write and bind a policy For the full picture, see [Policies](https://matthew-williams-solutions.github.io/pyquickbase/policies/). #### `Policy` What a client may do, as rules over tables and fields. Bind it to an app, then give the [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) to a client. ```python pay = ["Technicians.Hourly Rate"] policy = Policy( classes={"pay": pay}, rules=[ allow.read(tables.all()), allow.update( "Job Tasks", ["Status", "Notes"] ), deny.all(fields.classed("pay")), ], ) app = qb.app("bq1234567") bound = await policy.bind(app) ``` Anything no rule allows is refused, and a deny always wins. A formula, lookup, or summary is in every class of the fields it reads, through any chain of them, so it's denied wherever they are. ```python Policy( *, app: str | None = None, classes: Mapping[ str, Sequence[str | tuple[str, str | int] | FieldSelector], ] | None = None, rules: Sequence[PolicyRule | Rule] = (), new_fields: Literal["read", "hidden"] | str = "read", ) ``` **Parameters** - **`app`** `str | None`, optional The app's ID, which `bind()` checks, or None for any. - **`classes`** `Mapping[str, Sequence[str | tuple[str, str | int] | FieldSelector]] | None`, optional Names for groups of fields, as `{name: [fields]}`, for `fields.classed()`. Each field is `"Table.Field"`, `("Table", "Field")`, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector, such as `fields.labeled_like("_*")`. - **`rules`** `Sequence[PolicyRule | Rule]`, default `()` What's allowed and refused, from [`allow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#allow), [`deny`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#deny), and [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit), and any [`Rule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Rule). - **`new_fields`** `Literal['read', 'hidden'] | str`, default `'read'` What a field added after binding gets: `read`, to be read, filtered, and sorted by where a rule allows it, `hidden`, or a class's name, which also leaves it read-only. It's never writable until the policy is bound again. **Attributes** - **`needs_app`** `bool` Whether anything in it names a table or field, so it needs `bind()` before use. **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): A rule or class can't be used. ##### `Policy.read_only()` Read anything the token can, with no limit on records, and change nothing. It needs no binding, so a client takes it as it is. ```python read_only() -> Policy ``` **Returns** [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy) ##### `Policy.bind()` Resolve every table, field, and class against an app, once. Every problem is reported at once. With an [`AsyncApp`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/), this returns an awaitable. It reads the app's tables, each table's fields and relationships, and the reports of any table `allow.report()` lists by name, so use a client that can see them all. Quickbase only shows relationships to a role that can edit the app's structure. With any other role this still binds, but if the policy hides a field or limits rows, every lookup and summary field is hidden too, because what they read can't be traced. ```python bind( app: AsyncApp, ) -> Coroutine[Any, Any, BoundPolicy] bind(app: App) -> BoundPolicy ``` **Parameters** - **`app`** `App | AsyncApp`, required The app, from a client without a policy. **Returns** A [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy), which is frozen and keyed by ID. **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): A table, field, report, or class can't be found, or is ambiguous. The message lists every one. ##### `Policy.bind_offline()` Bind a policy that names no table or field, without reading an app. Every field then counts as existing, so `new_fields` doesn't apply. ```python bind_offline() -> BoundPolicy ``` **Returns** [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): It names a table, field, class, report, or row limit. Example: ```python from pyquickbase import ( Quickbase, Policy, allow, deny, fields, tables, ) policy = Policy( classes={"internal": [("Job Tasks", "Code")]}, rules=[ allow.read(tables.all(), max_paging_depth=50), allow.update("Job Tasks", ["Status"]), deny.all(fields.classed("internal")), ], ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") print( [ item.label for item in tasks.fields() if item.id > 5 ] ) ``` Output: ```text ['Title', 'Status', 'Hours', 'Due Date', 'Attachment'] ``` #### `BoundPolicy` A [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy) resolved against one app, keyed by ID, which decides every request a client sends. It's frozen: a client that creates a table gets a new one, as `client.policy`. Quickbase never reuses a field's ID within a table, so an ID pinned here can't come to mean another field. A field added after binding gets the policy's `new_fields`, and a renamed one keeps its rules, until the policy is bound again. **Attributes** - **`created`** `tuple[str, ...]` The IDs of the tables this client created after binding. - **`hash`** `str` A fingerprint of everything the policy decides with, which confirmations hold. ##### `BoundPolicy.classes_of()` The classes a field is in, including those it reads through formulas, lookups, and summaries. ```python classes_of( table_id: str, field_id: int ) -> frozenset[str] ``` **Parameters** - **`table_id`** `str`, required The table's ID. - **`field_id`** `int`, required The field's ID. **Returns** `frozenset[str]` The class names, which are empty for a field in none. ##### `BoundPolicy.decide()` Decide whether a request may be sent. This is the only code that decides. ```python decide( request: ReadRequest | WriteRequest, ) -> Decision ``` **Parameters** - **`request`** `ReadRequest | WriteRequest`, required From `describe_read()` or `describe_write()`. **Returns** [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) A [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision). A refusal names the first part refused, and the rule. ##### `BoundPolicy.allows()` Whether the policy allows a verb on a table, or on some of its fields. ```python bound.allows( "update", "Job Tasks", field="Labor Cost", ) bound.allows( "lookup", "Job Tasks", source=("Technicians", "Hourly Rate"), ) ``` ```python allows( verb: Verb, table: str, *, field: str | int | None = None, fields: Sequence[str | int] = (), source: tuple[str, str | int] | None = None, ) -> Decision ``` **Parameters** - **`verb`** `Verb`, required Such as `read`, `update`, or `lookup`. - **`table`** `str`, required The table, by name or ID. - **`field`** `str | int | None`, optional One field, by label or ID. - **`fields`** `Sequence[str | int]`, default `()` Several fields. - **`source`** `tuple[str, str | int] | None`, optional For `lookup` and `summary`, the other table's field the new one would show, which needs `read`. **Returns** [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) A [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision). **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): The policy doesn't know the table. - [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError): The policy doesn't know a field. ##### `BoundPolicy.explain()` What `allows()` decides, in words, with every rule that covers the table, and the classes of the fields involved. ```python explain( verb: Verb, table: str, *, field: str | int | None = None, fields: Sequence[str | int] = (), source: tuple[str, str | int] | None = None, ) -> str ``` **Parameters** - **`verb`** `Verb`, required Such as `read`, `update`, or `lookup`. - **`table`** `str`, required The table, by name or ID. - **`field`** `str | int | None`, optional One field, by label or ID. - **`fields`** `Sequence[str | int]`, default `()` Several fields. - **`source`** `tuple[str, str | int] | None`, optional For `lookup` and `summary`, the other table's field the new one would show. **Returns** `str` Several lines of text. ##### `BoundPolicy.max_records()` The most records one query may reach in a table, or None for no limit. ```python max_records(table_id: str) -> int | None ``` **Parameters** - **`table_id`** `str`, required The table's ID. **Returns** `int | None` ##### `BoundPolicy.row_limit()` The condition every query and delete of a table is ANDed with, by field ID, or None. ```python row_limit(table_id: str) -> str | None ``` **Parameters** - **`table_id`** `str`, required The table's ID. **Returns** `str | None` ##### `BoundPolicy.offers()` What the policy allows on a table, by field, as an operation's schema offers it. ```python offers(table: str) -> Offers[Named] ``` **Parameters** - **`table`** `str`, required The table, by name or ID. **Returns** [`Offers`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Offers) `[` [`Named`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Named) `]` An [`Offers`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Offers), whose lists hold each field's [`Named`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Named). **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): The policy doesn't know the table, or was bound without an app. ##### `BoundPolicy.to_json()` Save the bound policy, to use in another process without binding again. Custom [`Rule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Rule)s can't be saved, so pass them again to `from_json()`. ```python to_json() -> str ``` **Returns** `str` ##### `BoundPolicy.from_json()` Load a bound policy that `to_json()` saved. ```python from_json( text: str, *, rules: Sequence[Rule] = () ) -> BoundPolicy ``` **Parameters** - **`text`** `str`, required What `to_json()` returned. - **`rules`** `Sequence[Rule]`, default `()` The custom [`Rule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Rule)s the policy had, which can't be saved. **Returns** [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) **Raises** - [`ConfigurationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfigurationError): The text isn't a saved policy, or the custom rules don't match. Example: ```python from pyquickbase import ( Quickbase, BoundPolicy, Policy, allow, ) policy = Policy( rules=[allow.read("Job Tasks", ["Title", "Status"])] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) print(bound.explain("read", "Job Tasks", field="Hours")) saved = bound.to_json() print(BoundPolicy.from_json(saved) == bound) ``` Output: ```text Refused: reading Hours (8) in Job Tasks (bqtasks123). Layer: policy. Rule: none. Reason: No rule allows it. To allow it: add "Hours" to an allow.read("Job Tasks", ...) rule. Rules that cover it: allow.read('Job Tasks', ['Title', 'Status']). True ``` ### Decisions For the full picture, see [Debugging refusals](https://matthew-williams-solutions.github.io/pyquickbase/policies/debugging/). #### `Decision` What a [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) decided about a request, and why. It holds no values from the request, so it can be logged. A [`Rule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Rule) of your own returns one to refuse a request. ```python Decision( *, allowed: bool, verb: Verb, layer: Layer = "policy", rule: str | None = None, reason: str = "", hint: str | None = None, table: Named | None = None, fields: tuple[Named, ...] = (), ) ``` **Parameters** - **`allowed`** `bool`, required Whether the request may be sent. - **`verb`** `Verb`, required What was asked, such as `update`, for the first part refused, or the last allowed. - **`layer`** `Layer`, default `'policy'` Which check decided: `policy` for the rules, `rows` for a row limit, and `derived` for what a formula, lookup, or summary reads. A preview's confirmation isn't part of the policy, and raises [`ConfirmationError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#ConfirmationError). - **`rule`** `str | None`, optional The rule that decided, as written, such as `deny.all(fields.classed("pay"))`, or None when nothing allowed it. - **`reason`** `str`, default `''` Why, in a sentence. - **`hint`** `str | None`, optional What would allow it, or None when nothing should, such as for a deny. - **`table`** `Named | None`, optional The table it's about, if any. - **`fields`** `tuple[Named, ...]`, default `()` The fields it's about, if any. **Attributes** - **`what`** `str` What was asked, by name, such as `updating Labor Cost (6) in Job Tasks (bqtasks)`. ##### `Decision.message()` The refusal's message, in the form [`PolicyRefusedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PolicyRefusedError) uses. ```python message() -> str ``` **Returns** `str` Example: ```python from pyquickbase import Quickbase, Policy, allow policy = Policy(rules=[allow.update("Job Tasks", ["Status"])]) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) decision = bound.allows("update", "Job Tasks", field="Status") print(decision.allowed, decision.rule) print( bound.allows( "update", "Job Tasks", field="Hours" ).message() ) ``` Output: ```text True allow.update('Job Tasks', ['Status']) Refused by policy: updating Hours (8) in Job Tasks (bqtasks123). No rule allows it. (rule: none) Nothing was sent. To allow it: add "Hours" to an allow.update("Job Tasks", ...) rule. ``` #### `Named` A table or field, by its ID and its name when the policy was bound. **Attributes** - **`id`** `str | int` The table's ID, or the field's. - **`name`** `str` The table's name, or the field's label. Its ID, as text, if the policy doesn't know it. Example: ```python from pyquickbase import Named print(Named("bqtasks123", "Job Tasks")) ``` Output: ```text Job Tasks (bqtasks123) ``` #### `Offers` What a [`BoundPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#BoundPolicy) allows on one table, for building an operation's schema. Each list comes from asking `decide()` about one field and one verb at a time. **Attributes** - **`table`** `Named` The table. - **`readable`** `tuple[T, ...]` Fields it may return. - **`filterable`** `tuple[T, ...]` Fields it may filter on. - **`sortable`** `tuple[T, ...]` Fields it may sort by. - **`insertable`** `tuple[T, ...]` Fields a new record may set. - **`updatable`** `tuple[T, ...]` Fields an update may change. - **`downloadable`** `tuple[T, ...]` File fields it may download from. - **`file_deletable`** `tuple[T, ...]` File fields it may delete versions from. - **`can_delete`** `bool` Whether it may delete records. - **`reports`** `tuple[Any, ...]` Reports it may run. - **`schema`** `frozenset[str]` The schema verbs allowed on the table, such as `add_field`. - **`field_types`** `frozenset[str] | None` The field types `add_field` may create, or None for any. - **`max_paging_depth`** `int | None` The most records one query may reach, or None for no limit. - **`max_records_scanned`** `int | None` The most records one summary may read, or None for no limit. - **`max_records_per_delete`** `int | None` The most records one delete may remove, or None for no limit. - **`user_emails`** `bool` Whether user values keep email addresses. - **`row_limit`** `str | None` The condition every query is limited to, or None. Example: ```python from pyquickbase import Quickbase, Policy, allow policy = Policy( rules=[ allow.read("Job Tasks", ["Title", "Status"]), allow.update("Job Tasks", ["Status"]), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) offers = bound.offers("Job Tasks") print([item.name for item in offers.readable]) print([item.name for item in offers.updatable]) print(offers.can_delete, offers.max_paging_depth) ``` Output: ```text ['Record ID#', 'Title', 'Status'] ['Status'] False 100 ``` ## API reference: Rules Source: A policy is a list of rules. [`allow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#allow) and [`deny`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#deny) name what a client may do with tables and fields, [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit) keeps it to some records, and a [`Rule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#Rule) of your own can refuse anything else. Anything no rule allows is refused, and a deny always wins. Each example makes its own client from the environment, with `bqtasks123` standing for the ID of the `Job Tasks` table. A live test runs every example with both clients and checks its output. ### Allow, deny, and limit For the full picture, see [Writing rules](https://matthew-williams-solutions.github.io/pyquickbase/policies/rules/). #### `allow` The rules that grant. Anything no rule allows is refused, and a deny always wins. ##### `allow.filter()` Filtering on fields, which shows which records match, so it reveals something about their values. `read` already grants it, unless it's given its own `filter`. ```python filter( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.sort()` Sorting by fields, which reveals their order. `read` already grants it, unless it's given its own `sort`. ```python sort( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.insert()` Creating records that set these fields. An upsert needs `insert` and `update`. ```python insert( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields it may set, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Fields created after binding are never writable. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.update()` Changing these fields in existing records. Writing a field doesn't need read access to it. ```python update( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields it may change, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Fields created after binding are never writable. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.download()` Downloading files from file fields. `read` only returns a file's details. ```python download( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The file fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Defaults to all. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.delete_file()` Deleting versions of files. ```python delete_file( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The file fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Defaults to all. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.schema()` Changing tables' schema, granted on the table where the change lands. `add_field`, `change_field`, and `delete_field` change fields, and `formula` makes or changes a formula, which also needs `add_field` or `change_field`. `lookup` adds a lookup to a child table, and also needs `read` on the parent's field. `summary` adds a summary to a parent table, and also needs `read` on the child's field, and `filter` on every field its `where` uses. `link` and `unlink` need a grant on both tables, as a relationship adds a link field and an "Add" record formula to the parent, and `unlink` also needs `delete_field` wherever its lookups and summaries are. `change_table` and `delete_table` change or delete the table itself. ```python schema( tables: ( str | TableSelector | Sequence[str | TableSelector] ), *verbs: SchemaVerb, fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, field_types: Sequence[str] | None = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`verbs`** `SchemaVerb`, required What it grants, from those above. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional Which fields `change_field` and `delete_field` may change. Defaults to all. - **`field_types`** `Sequence[str] | None`, optional The types `add_field` may create, such as `["text", "date"]`, or None for any. A formula counts by its result type. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.create_tables()` Creating tables in the bound app. A table this client creates is matched by `tables.created()` and `tables.all()`. ```python create_tables() -> PolicyRule ``` **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.read()` Reading fields' values and details, and filtering and sorting by them. ```python read( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, *, filter: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, sort: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, max_paging_depth: int | None = 100, max_records_scanned: int | None = 10000, user_emails: bool = False, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The fields it may return, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Defaults to all. - **`filter`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The fields it may filter on instead, or None for the same as [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields). A filter shows which records match, so it reveals something about a field's values. - **`sort`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The fields it may sort by instead, or None for the same as [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields). - **`max_paging_depth`** `int | None`, default `100` The most records one query or report can reach, counting `skip`, or None for no limit. It's how far paging can go, not the size of a page. Where several rules read a table, the highest applies. - **`max_records_scanned`** `int | None`, default `10000` The most records `summarize()` may read for one summary, or None for no limit. - **`user_emails`** `bool`, default `False` Whether to keep email addresses in user values, and the uploader's details in file values. Without this, a user is only their `id` and `name`. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.delete()` Deleting records, which removes all of their fields. ```python delete( tables: ( str | TableSelector | Sequence[str | TableSelector] ), *, max_records_per_delete: int | None = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`max_records_per_delete`** `int | None`, optional The most records one delete may remove, or None for no limit. With a limit, the client counts what matches before deleting, and refuses a delete through `request()`, which it can't count. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `allow.report()` Running reports. A table report's columns are trimmed to what's readable, but its own filter and sort can use any field. A summary or chart report's columns can't be matched to fields, so it shows all of them. ```python report( table: ( str | TableSelector | Sequence[str | TableSelector] ), reports: Sequence[str | int] | None = None, ) -> PolicyRule ``` **Parameters** - **`table`** `str | TableSelector | Sequence[str | TableSelector]`, required The table, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector when `reports` is None. - **`reports`** `Sequence[str | int] | None`, optional The reports, by name or ID, or None for every report. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) Example: ```python from pyquickbase import ( Quickbase, F, Policy, PolicyRefusedError, allow, ) policy = Policy( rules=[ allow.read("Job Tasks"), allow.insert("Job Tasks", ["Title", "Status"]), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") result = tasks.insert([{"Title": "Paint walls"}]) print(result.created_ids) try: tasks.delete(F("Title") == "Paint walls") except PolicyRefusedError as error: print(error.decision.reason) print(error.decision.hint) ``` Output: ```text (4,) No rule allows it add allow.delete("Job Tasks") ``` #### `deny` The rules that refuse. A deny always wins over an allow. ##### `deny.filter()` Filtering on fields, which shows which records match, so it reveals something about their values. `read` already grants it, unless it's given its own `filter`. ```python filter( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.sort()` Sorting by fields, which reveals their order. `read` already grants it, unless it's given its own `sort`. ```python sort( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.insert()` Creating records that set these fields. An upsert needs `insert` and `update`. ```python insert( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields it may set, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Fields created after binding are never writable. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.update()` Changing these fields in existing records. Writing a field doesn't need read access to it. ```python update( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector]`, required The fields it may change, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Fields created after binding are never writable. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.download()` Downloading files from file fields. `read` only returns a file's details. ```python download( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The file fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Defaults to all. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.delete_file()` Deleting versions of files. ```python delete_file( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The file fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Defaults to all. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.schema()` Changing tables' schema, granted on the table where the change lands. `add_field`, `change_field`, and `delete_field` change fields, and `formula` makes or changes a formula, which also needs `add_field` or `change_field`. `lookup` adds a lookup to a child table, and also needs `read` on the parent's field. `summary` adds a summary to a parent table, and also needs `read` on the child's field, and `filter` on every field its `where` uses. `link` and `unlink` need a grant on both tables, as a relationship adds a link field and an "Add" record formula to the parent, and `unlink` also needs `delete_field` wherever its lookups and summaries are. `change_table` and `delete_table` change or delete the table itself. ```python schema( tables: ( str | TableSelector | Sequence[str | TableSelector] ), *verbs: SchemaVerb, fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, field_types: Sequence[str] | None = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`verbs`** `SchemaVerb`, required What it grants, from those above. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional Which fields `change_field` and `delete_field` may change. Defaults to all. - **`field_types`** `Sequence[str] | None`, optional The types `add_field` may create, such as `["text", "date"]`, or None for any. A formula counts by its result type. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.create_tables()` Creating tables in the bound app. A table this client creates is matched by `tables.created()` and `tables.all()`. ```python create_tables() -> PolicyRule ``` **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.read()` Refuse reading, filtering, and sorting by fields, which hides them unless something else, such as a write, is allowed. ```python read( tables: ( str | TableSelector | Sequence[str | TableSelector] ), fields: ( str | int | FieldSelector | Sequence[str | int | FieldSelector] | None ) = None, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. - **`fields`** `str | int | FieldSelector | Sequence[str | int | FieldSelector] | None`, optional The fields, by label or ID, or a [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector. Defaults to all. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.delete()` Refuse deleting records. ```python delete( tables: ( str | TableSelector | Sequence[str | TableSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.report()` Refuse running reports. ```python report( tables: ( str | TableSelector | Sequence[str | TableSelector] ), ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) ##### `deny.all()` Refuse everything on some fields, in every table, or on some tables. ```python deny.all(fields.classed("sensitive")) deny.all("Payroll") ``` ```python all( selector: ( FieldSelector | str | TableSelector | Sequence[str | TableSelector] ), ) -> PolicyRule ``` **Parameters** - **`selector`** `FieldSelector | str | TableSelector | Sequence[str | TableSelector]`, required A [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) selector, which hides those fields everywhere, or tables by name or ID, or a [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) Example: ```python from pyquickbase import Quickbase, Policy, allow, deny policy = Policy( rules=[ allow.read("Job Tasks"), deny.read("Job Tasks", ["Hours"]), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") print( [ item.label for item in tasks.fields() if item.id > 5 ] ) ``` Output: ```text ['Title', 'Status', 'Due Date', 'Code', 'Attachment'] ``` #### `limit` Limits on which records a client reaches. ##### `limit.rows()` Only reach the records that match a condition. It's ANDed onto every query, summary, and delete the client sends, and into a summary field's filter. This is defense in depth: Quickbase's record rules for the token's role are still the real boundary. Reports, file downloads, and formulas on the table are refused, as the limit can't be added to them, and so is a lookup from it, which would show its records anywhere. The fields the condition uses may be filtered on. ```python rows( tables: ( str | TableSelector | Sequence[str | TableSelector] ), where: Expression | str, ) -> PolicyRule ``` **Parameters** - **`tables`** `str | TableSelector | Sequence[str | TableSelector]`, required The tables, by name or ID. A [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables) selector works only if the condition names fields by ID. - **`where`** `Expression | str`, required The condition, built with [`F`](https://matthew-williams-solutions.github.io/pyquickbase/reference/queries/#F), or a query string, by label or ID. **Returns** [`PolicyRule`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#PolicyRule) Example: ```python from pyquickbase import ( Quickbase, F, Policy, allow, limit, ) policy = Policy( rules=[ allow.read("Job Tasks"), limit.rows("Job Tasks", F("Status") == "Open"), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") print(tasks.count()) print(tasks.count(F("Hours") > 7)) ``` Output: ```text 2 2 ``` #### `PolicyRule` One rule of a [`Policy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Policy), made by [`allow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#allow), [`deny`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#deny), or [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit). **Attributes** - **`effect`** `Literal['allow', 'deny', 'limit']` [`allow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#allow), [`deny`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#deny), or [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit). - **`tables`** `tuple[TableSelector, ...] | None` The tables it covers, or None for the app itself, as for `create_tables`. - **`grants`** `tuple[Grant, ...]` The verbs it covers, and on which fields. - **`options`** `Mapping[str, Any]` Its limits, such as `max_paging_depth`, and for `limit.rows()`, its `where`. - **`text`** `str` The rule as written, which a [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) names. Example: ```python from pyquickbase import allow rule = allow.update("Job Tasks", ["Status"]) print(rule, rule.effect) ``` Output: ```text allow.update('Job Tasks', ['Status']) allow ``` #### `Verb` What a policy grants or refuses, such as `read`, `update`, or `lookup`. ```python Verb = Literal[ "read", "filter", "sort", "insert", "update", "delete", "download", "delete_file", "report", "run_formula", "add_field", "change_field", "delete_field", "formula", "lookup", "summary", "link", "unlink", "change_table", "delete_table", "create_tables", ] ``` #### `SchemaVerb` What `allow.schema()` grants on a table, where the change lands. ```python SchemaVerb = Literal[ "add_field", "change_field", "delete_field", "formula", "lookup", "summary", "link", "unlink", "change_table", "delete_table", ] ``` #### `Layer` Which check made a [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision): `policy`, `rows`, `derived`, or `confirmation`. ```python Layer = Literal["policy", "rows", "derived"] ``` ### Tables and fields For the full picture, see [Writing rules](https://matthew-williams-solutions.github.io/pyquickbase/policies/rules/). #### `fields` Field selectors, for rules and classes. ##### `fields.all()` Every field, including ones added after binding, which `new_fields` limits. ```python all() -> FieldSelector ``` **Returns** [`FieldSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#FieldSelector) ##### `fields.new()` Only the fields added after the policy was bound. ```python new() -> FieldSelector ``` **Returns** [`FieldSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#FieldSelector) ##### `fields.classed()` The fields in any of these classes, including formulas, lookups, and summaries that read one, through any chain of them. ```python classed(*names: str) -> FieldSelector ``` **Parameters** - **`names`** `str`, required The classes, from the policy's `classes`. **Returns** [`FieldSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#FieldSelector) ##### `fields.labeled_like()` The fields whose labels match a pattern, ignoring case. Matched when the policy is bound. ```python labeled_like(pattern: str) -> FieldSelector ``` **Parameters** - **`pattern`** `str`, required The pattern, where `*` is any text and `?` is one character, such as `_*`. **Returns** [`FieldSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#FieldSelector) Example: ```python from pyquickbase import ( Quickbase, Policy, allow, deny, fields, ) policy = Policy( classes={"codes": [fields.labeled_like("cod*")]}, rules=[ allow.read("Job Tasks"), deny.all(fields.classed("codes")), ], ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") print( [ item.label for item in tasks.fields() if item.id > 5 ] ) ``` Output: ```text ['Title', 'Status', 'Hours', 'Due Date', 'Attachment'] ``` #### `tables` Table selectors, for rules. ##### `tables.all()` Every table in the bound app, and any this client creates. ```python all() -> TableSelector ``` **Returns** [`TableSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#TableSelector) ##### `tables.created()` Only the tables this client creates after the policy is bound. ```python created() -> TableSelector ``` **Returns** [`TableSelector`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#TableSelector) Example: ```python from pyquickbase import Quickbase, Policy, allow, tables policy = Policy( rules=[ allow.create_tables(), allow.read(tables.created()), allow.schema(tables.created(), "add_field"), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: sites = qb.app("bq1234567").create_table( "Job Sites" ) sites.create_field("Address", "text") print( [ item.label for item in sites.fields() if item.id > 5 ] ) print(len(qb.policy.created), len(bound.created)) ``` Output: ```text ['Address'] 1 0 ``` #### `FieldSelector` Which fields a rule or class covers. Make one with [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields), or name fields by label or ID. **Attributes** - **`kind`** `Literal['all', 'new', 'classed', 'labeled_like', 'named']` `all`, `new`, `classed`, `labeled_like`, or `named`. - **`values`** `tuple[str | int, ...]` The classes, the pattern, or the labels and IDs. Example: ```python from pyquickbase import fields print(fields.labeled_like("_*")) print(fields.classed("pay").kind) ``` Output: ```text fields.labeled_like('_*') classed ``` #### `TableSelector` Which tables a rule covers. Make one with [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables), or name a table by name or ID. **Attributes** - **`kind`** `Literal['all', 'created', 'named']` `all`, `created`, or `named`. - **`values`** `tuple[str, ...]` For `named`, the table's name or ID. Example: ```python from pyquickbase import tables print(tables.created()) ``` Output: ```text tables.created() ``` ### Rules of your own For the full picture, see [Rules of your own](https://matthew-williams-solutions.github.io/pyquickbase/policies/custom-rules/). #### `Rule` Custom logic, added to a policy's `rules`. It can only refuse. `decide()` gets each request the built-in rules allowed, and returns None to leave it, or a [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) with `allowed=False` to refuse it. `BoundPolicy.offers()` asks about one field and one verb at a time, so a rule that decides each field on its own is offered exactly what it allows. ##### `Rule.decide()` Refuse a request, or leave it to the other rules. ```python decide( request: ReadRequest | WriteRequest, ) -> Decision | None ``` **Parameters** - **`request`** `ReadRequest | WriteRequest`, required What the request would do, which holds no values. **Returns** [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) `| None` A [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision) with `allowed=False` to refuse, or None. Example: ```python from pyquickbase import ( Quickbase, Decision, Policy, PolicyRefusedError, WriteRequest, allow, fields, ) class OneRowAtATime: def decide(self, request): if ( isinstance(request, WriteRequest) and (request.row_count or 0) > 1 ): return Decision( allowed=False, verb="insert", reason="Write one row at a time", ) return None policy = Policy( rules=[ allow.read("Job Tasks"), allow.insert("Job Tasks", fields.all()), OneRowAtATime(), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") print( (tasks.insert([{"Title": "One"}])).created_ids ) try: tasks.insert([{"Title": "A"}, {"Title": "B"}]) except PolicyRefusedError as error: print(error.decision.reason) ``` Output: ```text (4,) Write one row at a time ``` #### `WriteRequest` What a write would do, for a policy to decide on. It holds no values from the request, so a policy can log it safely. `None` always means that this couldn't be determined, so check for it rather than treating it as nothing. **Attributes** - **`action`** `WriteAction` `insert` only creates records, and `update` only changes them. `upsert` may do either. That includes a write that merges on any field other than `Record ID#`, a row whose field IDs aren't written in the usual way, and any write without a merge field to a table whose key field isn't `Record ID#`, or through `request()`, which can't tell. `delete` deletes records, and `delete_file` deletes a version of a file. `create_table`, `update_table`, `delete_table`, `create_field`, `update_field`, `delete_field`, `create_relationship`, `update_relationship`, and `delete_relationship` change the schema. `update_relationship` adds lookup or summary fields. `other` is any other write. - **`table_id`** `str | None` The table it writes to. For a relationship, the child table. - **`field_ids`** `frozenset[int] | None` The fields whose values it sets, or for `update_field` and `delete_field`, the fields it changes or deletes. For `update`, this leaves out `Record ID#`, which only says which record to change. `None` for `delete`, which affects whole records, and for other schema changes. - **`row_count`** `int | None` How many rows are in this request, which is one batch of a larger write. `None` for anything but `insert`, `update`, and `upsert`, as a delete by filter can't know. - **`method`** `str` The HTTP method. - **`path`** `str` The request's path, without its query parameters. - **`app_id`** `str | None` For `create_table` and `update_table`, the app, and for `copy_app` and `delete_app`, the app copied or deleted. - **`parent_table_id`** `str | None` For a relationship, the parent table. Only `create_relationship` says which it is, so through `request()` it's `None` for the others. - **`field_type`** `str | None` For `create_field`, the new field's type, such as `text`. - **`derived`** `frozenset[DerivedKind]` The formula, lookup, and summary fields it makes or changes, which read other fields. A summary is made on the parent table. Example: ```python from pyquickbase import ( Quickbase, Policy, WriteRequest, allow, fields, ) class Log: def decide(self, request): if isinstance(request, WriteRequest): print(request.action, request.row_count) print(sorted(request.field_ids)) return None policy = Policy( rules=[ allow.read("Job Tasks"), allow.insert("Job Tasks", fields.all()), Log(), ] ) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") tasks.insert([{"Title": "Paint walls"}]) ``` Output: ```text insert 1 [6] ``` #### `ReadRequest` What a read would do, for a policy to decide on. It holds no values from the request, so a policy can log it safely. `None` always means that this couldn't be determined, so check for it rather than treating it as nothing. `Record ID#` appears like any other field. **Attributes** - **`action`** `ReadAction` `query` reads records, and `summarize` is a page that `summarize()` reads to count and total. `delete` deletes records by a filter, which a read policy checks because the number deleted shows how many matched. `report` runs a table report, and `summary_report` a summary or chart report. `file` downloads a file, and `formula` runs one. [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables), [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields), `relationships`, `reports`, `app`, and `roles` read metadata. `field_usage` reads where fields are used, and `app_events` the app's webhooks, notifications, and automations. `other` is anything else, including a report run through `request()`, which doesn't say what kind of report it is. A change to tables, fields, or relationships isn't a read. - **`table_id`** `str | None` The table it reads, or None for [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables), `app`, `roles`, `app_events`, and `other`. - **`field_ids`** `frozenset[int] | None` The fields it returns. For `file`, the file field, and for `field_usage`, the one field it asks about. None for a report, whose columns are chosen in Quickbase, and for other metadata. - **`filter_field_ids`** `frozenset[int] | None` The fields its filter uses, which is empty without one. None if the filter can't be read, and for a report, whose filter is its own. - **`sort_field_ids`** `frozenset[int] | None` The fields it sorts by. None if it doesn't say, as the table's default sort would apply, and for a report. - **`limit`** `int | None` The most records it asks for, which Quickbase calls `top`. None if it doesn't say, so Quickbase returns them all. - **`skip`** `int | None` How many matching records it passes over first. - **`report_id`** `str | None` The report it runs. - **`method`** `str` The HTTP method. - **`path`** `str` The request's path, without its query parameters. Example: ```python from pyquickbase import ( Quickbase, Policy, ReadRequest, allow, ) class Log: def decide(self, request): if isinstance(request, ReadRequest): print( request.action, sorted(request.field_ids or []), ) return None policy = Policy(rules=[allow.read("Job Tasks"), Log()]) with Quickbase.from_env() as setup: bound = policy.bind(setup.app("bq1234567")) with Quickbase.from_env(policy=bound) as qb: tasks = qb.app("bq1234567").table("Job Tasks") tasks.first(select=["Title"]) ``` Output: ```text tables [] fields [] query [3, 6] ``` #### `WriteAction` What kind of write a [`WriteRequest`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#WriteRequest) is. ```python WriteAction = Literal[ "insert", "update", "upsert", "delete", "delete_file", "create_table", "update_table", "delete_table", "create_field", "update_field", "delete_field", "create_relationship", "update_relationship", "delete_relationship", "create_app", "copy_app", "delete_app", "other", ] ``` #### `DerivedKind` A field whose values come from other fields: a `formula`, a `lookup` of a parent's field, or a `summary` of a child's. `WriteRequest.derived` lists the ones a write makes or changes. ```python DerivedKind = Literal["formula", "lookup", "summary"] ``` #### `ReadAction` What kind of read a [`ReadRequest`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#ReadRequest) is. ```python ReadAction = Literal[ "query", "summarize", "delete", "report", "summary_report", "file", "formula", "tables", "fields", "relationships", "reports", "app", "roles", "field_usage", "app_events", "other", ] ``` ## API reference: Operation Source: An [`Operation`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/) is one table operation offered to a model as a tool: a JSON Schema of the arguments it may send, and `run()`, which checks them against the same lists before calling the table. Get one from [`Table.operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#operation). The examples below use `search`, made as shown here. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") search = tasks.operation( "query", fields=["Title", "Status"] ) ``` ### What the methods return | Type | What it is | Returned by | |---|---|---| | [`OperationName`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#OperationName) | | [`name`](https://matthew-williams-solutions.github.io/pyquickbase/reference/operation/#name) | ### Describe it to a model For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `Operation.schema` The JSON Schema of the arguments a model may send, for a tool's input. It's self-contained, with no `$ref`, and a new dictionary each time, so you can add to it. ```python schema: dict[str, Any] ``` Example: ```python print(sorted(search.schema["properties"])) select = search.schema["properties"]["select"] print(select["items"]["enum"]) ``` Output: ```text ['limit', 'order_by', 'select', 'skip', 'where'] ['Title', 'Status'] ``` #### `Operation.name` What the operation does, such as `query`. ```python name: OperationName ``` Example: ```python print(search.name) ``` Output: ```text query ``` ### Run it For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `Operation.run()` Check a model's arguments, and run the operation. Arguments the schema doesn't allow are refused before anything is sent, with a message that says where the mistake is, and doesn't repeat the values sent. Spellings models tend to write for a closed value are read as the one they mean, such as `sort` for `order_by`, `"order": "DESC"` for `"direction": "desc"`, and `starts with` for `starts_with`. Labels must match exactly. ```python run( arguments: Mapping[str, Any], *, scope: Expression | None = None, ) -> Any ``` **Parameters** - **`arguments`** `Mapping[str, Any]`, required The arguments from the model's tool call. - **`scope`** `Expression | None`, optional A condition your code fixes, such as the user's project, that every record must match. It's added to every filter. An operation that names records, such as `update`, first checks that they all match it. A write can't set a field the scope uses, and `insert` fills those fields in from the scope, which must then be only `==` conditions joined with `&`. A report can't take one. **Returns** `Any` What the table method returns, such as a [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) for `query`, which [`tool_result`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#tool_result) takes. **Raises** - [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError): An argument can't be used, a record named isn't in `scope`, a write sets a field the scope uses, or a delete matches more than `max_records_per_call`. Nothing was sent, other than the query that checks the scope or counts a delete. - [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError): A record or value can't be written. Nothing was sent. Example: ```python page = search.run( { "where": { "field": "Status", "op": "==", "value": "Open", }, "select": ["Title"], "limit": 5, } ) print([task["Title"] for task in page.records]) ``` Output: ```text ['Pour slab', 'Frame walls'] ``` ## API reference: Tools for models Source: These turn results and errors into plain data for the result of a model's tool call, in the same shapes and value formats whatever the call. They work with any tool-calling framework, as `json.dumps()` always accepts what they return. [`Table.operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#operation) makes the tools themselves. Each example makes its own client from the environment, with `bqtasks123` standing for the ID of the `Job Tasks` table. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. ### Results For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `tool_result` Describe a result for a model, as the result of its tool call. ```python page = await tasks.query_page(limit=50) return tool_result(page, compact=True) ``` The result is `{"ok": true, ...}`, which `json.dumps()` always accepts, with a key for what it holds: | Value | Keys | |---|---| | [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page) | `records`, or `columns` and `rows` with `compact`, and `page` | | A list of records or dictionaries | The same. `page.total` is null, as it isn't known | | [`Record`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Record) or None | `record`, which is null for None | | [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult) | `created_ids`, `updated_ids`, and `unchanged_ids` | | A list of [`SummaryRow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryRow) | `groups`, each with `group`, `count`, and its totals, and `page` | | A `summarize` operation's result | As for a list of [`SummaryRow`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#SummaryRow), sorted and paged as asked | | [`UpdatePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#UpdatePreview) | `changes`, `unchanged_ids`, and `missing_ids` | | A `preview_update` operation's result | As for an [`UpdatePreview`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#UpdatePreview), with the approval keys | | A `preview_delete` operation's result | As for a [`Page`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#Page), with the approval keys | | [`SchemaPlan`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaPlan) | `changes` and `has_changes`, and the approval keys | | [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult) | `applied` and `already_done`, and `failed` and `not_attempted` too | | A list of [`TableSchema`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableSchema) | [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables), each with [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields) and `relationships` | | [`FormulaResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FormulaResult) | `value` and `type` | | A `count` operation's result | `count` | | A `delete` operation's result | `deleted_count` | | Text, a number, a date, or a bool | `value` | | [`TableInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#TableInfo), [`Field`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#Field), or [`ReportInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#ReportInfo) | `table`, `field`, or `report`, without `raw` | | A list of one of those | [`tables`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#tables), [`fields`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#fields), or `reports` | | [`FileDownload`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#FileDownload) | `file`, with `file_name`, `content_type`, `size`, and `text` or `binary` | A file's `text` is its contents if they're UTF-8 text, and otherwise it's null and `binary` is true, as a model can't read bytes. `page` holds `returned`, `total`, `skip`, `next_skip`, and `has_more`, so a model can tell whether it has every record, and ask for the next page. An empty list counts as no records. For a summary, `page` counts groups. A `query` operation that left out some of the table's default fields, because it has more than its `max_default_fields`, adds `fields_left_out` with how many and a `hint`. A preview's approval keys are `blockers`, what would stop the change, `warnings`, what else it would do, and, if nothing stops it, the `confirmation` the change needs and the UTC time it `expires_at`. A `preview_delete` operation's result has a blocker when more records match than the operation's `max_records`, as a delete with the same filter would be refused. Give both operations the same `max_records` for this to be right. Values are written the same way whatever their field: a date as `YYYY-MM-DD`, a timestamp in UTC as `2026-03-04T05:06:07Z`, a `Decimal` as a number when that's exact and as text otherwise, and every kind of empty value, such as `""` or an empty file, as null. Records are keyed by label. ```python tool_result( value: Any, *, compact: bool = False, max_records: int | None = None, max_text_chars: int | None = None, max_chars: int | None = None, ) -> dict[str, Any] ``` **Parameters** - **`value`** `Any`, required The result to describe. - **`compact`** `bool`, default `False` Send each label once, as `columns`, with a list of values for each record in `rows`, rather than repeating every label on every record. A file value becomes the latest file's name. Use it when a tool returns many records, to save tokens. - **`max_records`** `int | None`, optional The most records to include. Any more are left out, and `page.has_more` is true, so a model knows it doesn't have them all. - **`max_text_chars`** `int | None`, optional The most characters to include of each text value. A longer one is cut and ends by saying how much was left out, and `text_truncated` is true. - **`max_chars`** `int | None`, optional About the most characters the result may take as compact JSON, so that one call can't fill a model's context. Records and groups are included from the first until the next wouldn't fit, then `page.has_more` is true and `page.next_skip` says where to carry on, so paging still reaches every one. At least one is always included: if it's too large on its own, its text is cut shorter until it fits, down to 50 characters a value. Other keys, such as a file's text, aren't limited by it. **Returns** `dict[str, Any]` The result, as plain data. **Raises** - `TypeError`: The value isn't one of these, such as an [`AppInfo`](https://matthew-williams-solutions.github.io/pyquickbase/reference/metadata/#AppInfo), or holds a value of another type. - `ValueError`: `max_records`, `max_text_chars`, or `max_chars` isn't a positive whole number. Example: ```python import json from pyquickbase import Quickbase, Policy, tool_result with Quickbase.from_env( policy=Policy.read_only() ) as qb: tasks = qb.app("bq1234567").table("bqtasks123") page = tasks.query_page( select=["Title", "Due Date"], limit=2 ) result = tool_result(page, compact=True) print(json.dumps(result, indent=2)) ``` Output: ```text { "ok": true, "columns": [ "Record ID#", "Title", "Due Date" ], "rows": [ [ 1, "Pour slab", "2027-01-15" ], [ 2, "Frame walls", "2027-02-01" ] ], "page": { "returned": 2, "total": 3, "skip": 0, "next_skip": 2, "has_more": true } } ``` ### Errors For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `tool_error` Describe an exception for a model, as the result of its tool call. ```python try: page = await tasks.query_page( where=where, limit=50 ) return tool_result(page) except QuickbaseError as error: return tool_error(error) ``` The result is `{"ok": false, "error": {...}}`, which `json.dumps()` always accepts. `error` holds `code`, `message`, `hint`, `retryable`, and `data_changed`, and more for some codes: - `code` is one of [`ToolErrorCode`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#ToolErrorCode), which doesn't change between 1.x versions, unlike the wording of `message`. - `hint` says what the model should do next, in fixed words for each code. A failed schema change has its own, as it has no failed rows, and so does a `partial_write` whose `data_changed` is `unknown`, as its failed rows may have been written. - `retryable` is true when the same call can succeed later, such as after a rate limit or a timeout. `retry_after` is added when Quickbase said how long to wait. - `data_changed` is `no` if nothing was changed, `partly` if part of a write was, and `unknown` if a write may have been applied, such as after a timeout. A model should check the data before repeating a call whose `data_changed` isn't `no`. - A [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError) adds `written`, with the IDs that were created, updated, and left unchanged, and `failed_rows`, with each row's `index`, `reason`, `field_id`, and Quickbase's `messages`. - An [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) adds `status`, the HTTP status. - A [`TooManyRecordsError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#TooManyRecordsError) adds `matched`, how many records match, and [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit), the most the summary may read. An exception from outside the SDK gets the code `internal` and a message that says nothing about it, so a bug's details and any values in it don't reach the model. Log it yourself. ```python tool_error(error: BaseException) -> dict[str, Any] ``` **Parameters** - **`error`** `BaseException`, required The exception, usually a [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError). **Returns** `dict[str, Any]` The error, as plain data. Example: ```python from pyquickbase import ( Quickbase, Policy, QuickbaseError, tool_error, ) with Quickbase.from_env( policy=Policy.read_only() ) as qb: tasks = qb.app("bq1234567").table("bqtasks123") try: tasks.insert([{"Title": "Paint walls"}]) except QuickbaseError as error: found = tool_error(error)["error"] print(found["code"], found["data_changed"]) print(found["hint"]) ``` Output: ```text write_refused no This tool may not make that change. Don't try it another way. Tell the user. ``` #### `ToolErrorCode` The `code` in what [`tool_error`](https://matthew-williams-solutions.github.io/pyquickbase/reference/tools/#tool_error) returns. Codes don't change within 1.x, so a tool can act on them. ```python ToolErrorCode = Literal[ "misconfigured", "needs_confirmation", "invalid_arguments", "too_many_records", "invalid_value", "read_refused", "write_refused", "unknown_table", "unknown_field", "unknown_report", "unsupported_report", "schema_mismatch", "partial_write", "write_failed", "write_permission_denied", "authentication", "permission_denied", "not_found", "bad_request", "rate_limited", "server_error", "api_error", "timeout", "network_error", "invalid_response", "quickbase_error", "internal", ] ``` ### Operations For the full picture, see [Using pyquickbase from an AI agent](https://matthew-williams-solutions.github.io/pyquickbase/agents/). #### `OperationName` What [`Table.operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/table/#operation) can offer a model, each running the table or report method of the same name. ```python OperationName = Literal[ "query", "count", "summarize", "insert", "update", "delete", "preview_update", "preview_delete", "run_report", "run_summary_report", "run_formula", "download_file", "delete_file_version", ] ``` #### `SchemaOperationName` What [`App.operation()`](https://matthew-williams-solutions.github.io/pyquickbase/reference/app/#operation) can offer a model. ```python SchemaOperationName = Literal[ "describe_schema", "preview_schema_changes", "apply_schema_changes", ] ``` ## API reference: Errors Source: Everything the SDK raises derives from [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError). Errors that mean a request wasn't sent come first, then the ones for a request that failed. A timed-out write may still have been applied. The examples below use `tasks`, made as shown here, and `app`, which is `qb.app("bq1234567")`. `Job Tasks` has `Title`, `Status`, `Hours`, `Due Date`, `Code`, which is unique, and `Attachment`. It starts with three records, and each example starts from those again. A live test runs every example with both clients and checks its output. ```python from pyquickbase import Quickbase with Quickbase( realm="acme", token="your_user_token" ) as qb: tasks = qb.app("bq1234567").table("Job Tasks") ``` ### The base class For the full picture, see [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/). #### `QuickbaseError` Base class for every exception this library defines. Example: ```python from pyquickbase import QuickbaseError try: tasks.get(1, select=["No Such Field"]) except QuickbaseError as error: print(type(error).__name__) ``` Output: ```text UnknownFieldError ``` ### Mistakes caught before sending For the full picture, see [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/). #### `ConfigurationError` The client was given invalid or missing settings. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import ConfigurationError try: qb.app("not an app ID") except ConfigurationError as error: print(error) ``` Output: ```text An app ID contains only letters and numbers. ``` #### `QueryError` A query can't be sent as written, so it wasn't sent to Quickbase. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import QueryError try: tasks.count("{7.ex.'Open'}") except QueryError: print("A lowercase operator was refused") ``` Output: ```text A lowercase operator was refused ``` #### `TooManyRecordsError` More records match a summary than its `max_records_scanned` lets it read. None were read. A summary that only counts reads no records, so this is raised only for one that groups, totals, averages, or finds the lowest or highest value. **Inherits from** [`QueryError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QueryError) **Attributes** - **`matched`** `int` How many records match. - **`limit`** `int` The most records the summary may read. Example: ```python from pyquickbase import TooManyRecordsError try: tasks.summarize( "Status", sum="Hours", max_records_scanned=1 ) except TooManyRecordsError as error: print(error.matched > error.limit, error.limit) ``` Output: ```text True 1 ``` #### `ConfirmationError` A change needs the confirmation its preview or plan returned, and this one is missing, expired, or for something else. Nothing was changed. Preview or plan again, and show the person the result. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import ConfirmationError update = tasks.operation("update", require_preview=True) try: update.run( {"records": [{"Record ID#": 1, "Status": "Done"}]} ) except ConfirmationError: print("Preview it first") ``` Output: ```text Preview it first ``` #### `InvalidValueError` A write can't be sent as written, so it wasn't sent to Quickbase. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import InvalidValueError try: tasks.insert([{"Record ID#": 1, "Title": "Oops"}]) except InvalidValueError: print("Nothing was written") ``` Output: ```text Nothing was written ``` #### `FileTooLargeError` A file is over a limit on how much of it may be read, so reading stopped. **Inherits from** [`InvalidValueError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#InvalidValueError) **Attributes** - **`limit`** `int` The limit it went over. - **`unit`** `Literal['rows', 'bytes']` What [`limit`](https://matthew-williams-solutions.github.io/pyquickbase/reference/rules/#limit) counts: `rows`, for `max_rows`, or `bytes`, for `max_unzipped_bytes`. Example: ```python import io from pyquickbase import FileTooLargeError try: tasks.rows_from_file( io.StringIO("Title\nOne\nTwo\n"), max_rows=1 ) except FileTooLargeError as error: print(error.limit, error.unit) ``` Output: ```text 1 rows ``` #### `PolicyRefusedError` The client's `policy` doesn't allow this request, so it wasn't sent. The message says what was refused, by name, which rule refused it, and what would allow it, without repeating any values from the request. Quickbase's own permissions still apply to whatever the policy allows. A field the policy hides raises [`UnknownFieldError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#UnknownFieldError) instead, as if it didn't exist. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) **Attributes** - **`decision`** `Any` The [`Decision`](https://matthew-williams-solutions.github.io/pyquickbase/reference/policies/#Decision), with the rule, the reason, and the tables and fields involved. Example: ```python from pyquickbase import ( Quickbase, Policy, PolicyRefusedError, ) with Quickbase.from_env( policy=Policy.read_only() ) as qb: tasks = qb.app("bq1234567").table("bqtasks123") try: tasks.insert([{"Title": "Paint walls"}]) except PolicyRefusedError as error: print(error.decision.verb, error.decision.layer) print(error.decision.reason) ``` Output: ```text insert policy No rule allows it ``` ### Names that don't match For the full picture, see [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/). #### `UnknownTableError` The app has no table with that name or ID. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import UnknownTableError try: app.table("Job Task").info() except UnknownTableError: print("There's no such table") ``` Output: ```text There's no such table ``` #### `UnknownFieldError` The table has no field with that label or ID. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import F, UnknownFieldError try: tasks.count(F("Statuss") == "Open") except UnknownFieldError as error: print(error) ``` Output: ```text This table has no field labeled 'Statuss', or this client can't see it. Did you mean 'Status'? ``` #### `UnknownReportError` The table has no report with that name or ID, or several reports share the name. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import UnknownReportError try: tasks.report("No Such Report").info() except UnknownReportError: print("There's no such report") ``` Output: ```text There's no such report ``` #### `UnsupportedReportError` The report can't be read as records, such as a summary or chart report. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import UnsupportedReportError try: tasks.report("List All").summary_rows() except UnsupportedReportError: print("That's a table report, so use run()") ``` Output: ```text That's a table report, so use run() ``` #### `SchemaMismatchError` A table doesn't match what the code expects, so nothing was changed. An `ensure_` method found a field that isn't what it was asked for, or the schema changed since a plan was made. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) Example: ```python from pyquickbase import SchemaMismatchError try: tasks.ensure_field("Title", "numeric") except SchemaMismatchError: print("Title isn't numeric, and wasn't changed") ``` Output: ```text Title isn't numeric, and wasn't changed ``` ### Writes For the full picture, see [Writing records](https://matthew-williams-solutions.github.io/pyquickbase/guides/writing/). #### `WriteError` Part of a write failed, and the rest may have been applied. When a later batch of a large write fails, `__cause__` holds that batch's own error, such as a [`RateLimitError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#RateLimitError) or [`RequestTimeoutError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#RequestTimeoutError). **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) **Attributes** - **`result`** `Any` A [`WriteResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/records/#WriteResult) with the records that were written and the rows that failed, when Quickbase reported them. Example: ```python from pyquickbase import WriteError try: tasks.insert([{"Title": "Repeat", "Code": "T-1"}]) except WriteError as error: print(error.result.errors[0].reason) ``` Output: ```text duplicate ``` #### `WritePermissionError` A write was refused because of the token's role, so nothing in the failed rows changed. Quickbase refuses some writes with a 403, and reports others as row errors in a successful response, depending on the role. Both raise this, so `except PermissionDeniedError` and `except WriteError` each catch it. `result` holds what earlier batches or rows did write. If some rows failed for other reasons as well, you get a plain [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError), whose rows each have a `kind`. **Inherits from** [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError), [`PermissionDeniedError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#PermissionDeniedError) **Attributes** - **`status_code`** `int` 403 for a refused request, and 207 for row errors. ```python class WritePermissionError(WriteError, PermissionDeniedError) ``` #### `SchemaChangeError` A change in a schema plan failed, so the ones after it weren't attempted. Quickbase can't undo the changes before it, so `result` says which were made. Planning the same changes again shows only what's left, as the ones made count as already done. **Inherits from** [`WriteError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#WriteError) **Attributes** - **`result`** `Any` A [`SchemaChangeResult`](https://matthew-williams-solutions.github.io/pyquickbase/reference/schema-changes/#SchemaChangeResult). ```python class SchemaChangeError(WriteError) ``` ### Responses from Quickbase For the full picture, see [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/). #### `APIError` Quickbase answered with an unsuccessful status code. This is the base of the errors for each status, so it's what you catch for any of them. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) **Attributes** - **`status_code`** `int` The HTTP status. - **`method`** `str` The request's HTTP method. - **`path`** `str` The request's path. - **`message`** `str | None` Quickbase's wording, when it sent any. It can contain HTML, so escape it before showing it in a web page. - **`description`** `str | None` Quickbase's further explanation, when it sent any. - **`request_id`** `str | None` Include this when reporting a problem to Quickbase support. Example: ```python from pyquickbase import APIError try: qb.request( "GET", "/tables/bzzzzzzzz", params={"appId": "bq1234567"}, ) except APIError as error: print(error.status_code, error.method) print(error.message) ``` Output: ```text 404 GET Invalid DBID ``` #### `BadRequestError` 400: Quickbase rejected the request as malformed. **Inherits from** [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) Example: ```python from pyquickbase import BadRequestError try: app.create_table("Job Tasks") except BadRequestError as error: print(error.status_code) ``` Output: ```text 400 ``` #### `AuthenticationError` 401: the token is invalid, or the app or table doesn't exist or is out of the token's reach. Quickbase gives the same answer for all of these, so this can't say which one happened. **Inherits from** [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) Example: ```python from pyquickbase import AuthenticationError try: qb.app("bzzzzzzzz").info() except AuthenticationError as error: print(error.status_code) print(error.message) ``` Output: ```text 401 Access denied ``` #### `PermissionDeniedError` 403: the token's user may not perform this action. **Inherits from** [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) ```python class PermissionDeniedError(APIError) ``` #### `NotFoundError` 404: what was asked for doesn't exist. An app that doesn't exist is a 401 instead. **Inherits from** [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) Example: ```python from pyquickbase import NotFoundError try: tasks.download_file(999, "Attachment") except NotFoundError as error: print(error.status_code) ``` Output: ```text 404 ``` #### `RateLimitError` 429: retries ran out, or Quickbase asked for a longer wait than the retry policy allows. **Inherits from** [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) **Attributes** - **`retry_after`** `float | None` The wait Quickbase asked for, in seconds, if it did. ```python class RateLimitError(APIError) ``` #### `ServerError` 5xx: Quickbase failed to process the request. **Inherits from** [`APIError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#APIError) ```python class ServerError(APIError) ``` ### Connection failures For the full picture, see [Handling errors](https://matthew-williams-solutions.github.io/pyquickbase/guides/errors/). #### `NetworkError` The request failed before a response was received. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) ```python class NetworkError(QuickbaseError) ``` #### `RequestTimeoutError` The request timed out. A timed-out write may still have been applied. **Inherits from** [`NetworkError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#NetworkError) Example: ```python from pyquickbase import ( Quickbase, NetworkError, RetryPolicy, ) no_retries = RetryPolicy(max_retries=0) with Quickbase.from_env( timeout=0.0001, retry=no_retries ) as qb: try: qb.app("bq1234567").tables() except NetworkError as error: print(type(error).__name__) ``` Output: ```text RequestTimeoutError ``` #### `InvalidResponseError` Quickbase reported success, but the response body wasn't in the expected format. **Inherits from** [`QuickbaseError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#QuickbaseError) ```python class InvalidResponseError(QuickbaseError) ``` ## API reference: Retries Source: A [`RetryPolicy`](https://matthew-williams-solutions.github.io/pyquickbase/reference/retries/#RetryPolicy) says how a client retries a request that hit a rate limit or a connection failure, and how long it waits. Pass one to [`Quickbase`](https://matthew-williams-solutions.github.io/pyquickbase/reference/quickbase/) as `retry`. Each example makes its own client from the environment, with `bqtasks123` standing for the ID of the `Job Tasks` table. A live test runs every example with both clients and checks its output. ### Retrying requests For the full picture, see [Configuring clients](https://matthew-williams-solutions.github.io/pyquickbase/guides/clients/). #### `RetryPolicy` How to retry rate limits, connection failures, and reads that failed on the way. ```python RetryPolicy( *, max_retries: int = 4, base_delay: float = 1.0, max_delay: float = 30.0, max_retry_after: float = 60.0, ) ``` **Parameters** - **`max_retries`** `int`, default `4` Retries after the first attempt. Use 0 to disable retrying. - **`base_delay`** `float`, default `1.0` Starting backoff in seconds, when Quickbase doesn't specify a wait or says to wait 0 seconds. - **`max_delay`** `float`, default `30.0` Longest backoff in seconds. - **`max_retry_after`** `float`, default `60.0` Raise [`RateLimitError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#RateLimitError) rather than obey a longer `Retry-After`. ##### `RetryPolicy.backoff()` Exponential backoff with jitter, so parallel clients don't retry in lockstep. ```python backoff(attempt: int) -> float ``` **Parameters** - **`attempt`** `int`, required Which retry this is, starting from 0. **Returns** `float` The seconds to wait. ##### `RetryPolicy.rate_limit_delay()` Seconds to wait after a 429, or None if the wait is too long to be worth it. Quickbase can send a wait of 0 while it's still limiting, so an immediate retry could use up the retries, and that's treated like no wait at all. ```python rate_limit_delay( attempt: int, retry_after: float | None ) -> float | None ``` **Parameters** - **`attempt`** `int`, required Which retry this is, starting from 0. - **`retry_after`** `float | None`, required The wait Quickbase asked for, in seconds, if it did. **Returns** `float | None` The seconds to wait, or None to stop retrying and raise [`RateLimitError`](https://matthew-williams-solutions.github.io/pyquickbase/reference/errors/#RateLimitError). Example: ```python from pyquickbase import Quickbase, RetryPolicy patient = RetryPolicy(max_retries=6, max_delay=60) with Quickbase.from_env(retry=patient) as qb: tasks = qb.app("bq1234567").table("bqtasks123") print(tasks.count()) ``` Output: ```text 3 ```