Writing records¶
This page covers creating, updating, and deleting records, and what to do when rows fail.
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 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 |
| Anything else | Raises InvalidValueError |
Creating records¶
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 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.
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.
result = tasks.update([{"Record ID#": 7, "Status": "Done", "Notes": None}])
print(result.updated_ids, result.unchanged_ids)
Try it in the playground, 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.
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.
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, orNone, leaves the field empty ininsert(), and clears it inupdate()andupsert(). 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
datetimedoes, so convert a naive column withtz_localize()first. - Rows are checked as dictionaries are, so a column that isn't a field raises
UnknownFieldErrorand 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.
from pyquickbase import F
deleted = tasks.delete(F("Status") == "Cancelled")
print(deleted)
Try it in the playground, 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.
preview_update() takes the same rows as update():
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:
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.
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
WriteResultafterward. - 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 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.
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:
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. 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, 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.
flowchart TD
I["insert(2,500 rows)"] --> B1["<b>Batch 1</b> is written<br/>rows 1 to 1,000"]
B1 --> B2["<b>Batch 2</b> is written<br/>rows 1,001 to 2,000"]
B2 --> B3["<b>Batch 3 fails</b><br/>Refused: not written<br/>Timed out: may be written"]
B3 --> E["<b>WriteError</b><br/>error.result holds<br/>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 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 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 for how these exceptions relate.