Course / Writing data / Lesson 20

Lesson 20 · Writing data

insert, update, delete

Write single records with the three table commands and know what each one does with the record, the key and the database values.

about 7 minutes · screenplay estimate9 scenesPrerequisites: lessons 3, 4, 14, 19

Development course · Reading edition · API compatibility

Scene 01

Three commands on the table

A table object has three commands for single-record writes. They are insert, update and delete. Each command works on one record. The record is a plain Python dictionary. Its keys are the column names of the model, written without the dollar sign. The dollar sign belongs to query expressions, not to records. Insert and update change the record you pass, in place. Then they return that same object. You do not get a new object or a result set. Delete returns nothing. None of the three commands commits. The write stays pending until you call commit on the database, as you saw in lesson 19 for reads with a lock.

Example & screen notes

slide "Three commands". Three signatures, one per line:

table.insert(record)
table.update(record, old_record=None, pkey=None)
table.delete(record_or_key)

Below, three bullets: - record is a plain dict - keys are column names, without $ - insert and update return the same object they received

Scene 02

insert: the record comes back filled

Here we insert a customer of the course model. The record has a key called full address. In the model, full address is a formula column. A formula column has no physical column, so insert ignores that key. The same holds for alias columns, for keys that start with an at sign, and for keys that are not columns at all. They are not an error. They are simply not written. The email key holds None. Insert leaves None values out of the INSERT statement. The column then gets its database default, or NULL when it has none. After the call, the record holds new keys. The id was generated. The timestamps and the user columns come from the system fields of the table. Lesson 25 explains them. The point here is that the record you passed is the record that was written.

Example & screen notes

code on the left, output on the right. The line result = customer.insert(record) is highlighted. The rows of state, region and customer_type were inserted before.

db = AsqueelDb(Invoicing)
customer = db.table('invc.customer')
record = {'account_name': 'Acme', 'state': 'MA', 'customer_type_code': 'B2B',
          'email': None, 'full_address': 'ignored'}
result = customer.insert(record)
assert result is record
{'account_name': 'Acme', 'state': 'MA', 'customer_type_code': 'B2B',
 'email': None, 'full_address': 'ignored',
 'id': 'Z0G7qW9ivPGjKnhar9FYwN',
 '__ins_ts': datetime(2026, 10, 20, 7, 25, 49, tzinfo=utc),
 '__mod_ts': datetime(2026, 10, 20, 7, 25, 49, tzinfo=utc),
 '__ins_user': None, '__mod_user': None}

Bullets under the output: - full_address is a formulaColumn: ignored - email is None: left out of the INSERT - id and the __ columns: added by the write

Scene 03

Key generation

Before the INSERT, the write checks the primary key. The method is called check pkey. It acts when the key is None or an empty string. When the key column declares an SQL default, the database assigns the key and nothing is done in Python. When the key is an integer, the next key is the current maximum plus one. This value is read without a lock. Two connections that insert at the same time can compute the same key, so this strategy is not safe under concurrency. When the key is a text column of size twenty-two, or a text column with no size, the key is a new twenty-two-character id. These ids sort in creation order. To choose your own keys, define new pkey value on a table subclass. You attach the subclass to the table with the x table class attribute. In the example, the region code becomes the first two letters of the name.

Example & screen notes

slide "When the key is missing". A decision list:

  1. key column declares sql_default → the database assigns it
  2. integer key (L, I, R) → max(key) + 1
  3. text key (T, A, C) with size 22, :22 or no size → 22-character id
  4. no rule applies → no value is assigned

Footnote: pkey_columns and __syscode have rules of their own, not covered here.

Below, the override:

class Region(SqlTable):
    def newPkeyValue(self, record=None):
        return record['name'][:2].upper()

tbl = pkg.table('region', pkey='code', name_long='Region', x_table_class=Region)
region.insert({'name': 'south west'})  ->  {'name': 'south west', 'code': 'SO', ...}

Scene 04

Values decided by the database

Some values are decided by the database, not by Python. A column default, an SQL value expression and a generated column are examples. For these columns the INSERT ends with a RETURNING clause. The returned values are written into your record. In the example, the opened column has an SQL default. We do not pass it, so the database fills it, and the record shows it after the call. The num key was generated as the maximum plus one. Update does the same for SQL value expressions and generated columns. Only those columns are read back. The other columns of the record are what you passed and what the hooks changed. RETURNING works on PostgreSQL and on SQLite. SQLite must be version 3.35 or later.

Example & screen notes

slide "RETURNING". An addition to the course recipe:

def ticket(pkg):
    t = pkg.table('ticket', pkey='num')
    t.column('num', dtype='L')
    t.column('subject')
    t.column('opened', dtype='DH', sql_default='CURRENT_TIMESTAMP')
ticket = db.table('invc.ticket')
ticket.insert({'subject': 'first'})
# {'subject': 'first', 'num': 1, 'opened': datetime(2026, 10, 20, 9, 26, 26)}

Bullets: - RETURNING reads back only the columns the database decides - sql_default (when the value is missing), sql_value, sql_generatedExpression - PostgreSQL and SQLite 3.35 or later

Scene 05

update: where the key comes from

Update takes the record, an optional old record and an optional key. The key of the WHERE clause comes from the old record first. If there is no old record, it comes from the pkey argument. If there is neither, it comes from the record itself. The SET clause contains only the columns present in the record, None included. So a partial record changes only its own columns. The second call shows this. It changes the subject of ticket two and nothing else. The third call changes the key itself. The old record names the row, and the new record carries the new key value.

Example & screen notes

slide "update(record, old_record=None, pkey=None)". Three numbered rows:

  1. old_record given → key of old_record
  2. else pkey given → pkey
  3. else → key in record

Code below:

rec = ticket.query(columns='$num,$subject', where='$num=1', for_update=True).fetchRecord()
old = dict(rec)
new = dict(old, subject='first, edited')
ticket.update(new, old)

ticket.update({'subject': 'renamed'}, pkey=2)
ticket.update({'num': 20, 'subject': 'moved'}, old_record={'num': 2})

Scene 06

old_record is never read for you

Update never reads the row from the database. If you pass no old record, the hooks receive None as old record. If you need the old values, read the row yourself. Read it with for update equal to True, so that no other connection changes it before your write. Lesson 19 showed this lock. Then pass a copy of the row as old record, and change another copy. Another rule follows from the absence of a read. When no row matches the key, update raises no error. The UPDATE statement changes zero rows. Lesson 26 shows two helpers that do the read and the update for you.

Example & screen notes

slide with two bullets, the first highlighted: - update does not read the row before writing - read it yourself with for_update=True, then pass a copy as old_record

Small box at the bottom: "No matching row: no error, nothing changed."

Scene 07

delete: a key or a record

Delete accepts a key string or a record. With a key string, delete reads the row again with a lock. The read ignores the draft, logical deletion and partition filters. When the row is missing, delete returns without an error. With a dictionary, delete does not read the row. It uses the dictionary as it is, for the hooks and for the WHERE clause. The DELETE statement must remove exactly one row. Otherwise it raises record not found error. A table declared with deletable equal to False refuses every delete with a delete exception. After an error raised inside a write, the connection accepts only a rollback. Lesson 21 explains why.

Example & screen notes

two columns.

Left, "a key string":

ticket.delete('20')      # reread with a lock, then deleted
ticket.delete('999')     # no row: returns None, no error

Right, "a dict":

ticket.delete({'num': 999})
# RecordNotFoundError: invc.ticket: DELETE removed 0 rows instead of 1

Below, a declaration and its error:

t3 = pkg.table('vat_history', pkey='id', deletable=False)
DeleteException: The record 1 in table invc.vat_history cannot be deleted:
The records of this table cannot be deleted

Scene 08

Composite keys and Bag values

A composite key is a JSON string. It lists the members in the order of the composite column. Dates and decimals carry a type suffix, such as colon colon D for a date. Insert writes this string into the record, under the name of the composite column. Update and delete accept the same string as key. A last rule concerns Bag values. When a record value is a Bag, the write stores its XML text. An empty Bag is stored as NULL.

Example & screen notes

an addition to the recipe and its use.

def price_list(pkg):
    t = pkg.table('price_list', pkey='pk')
    t.column('product_code', size=':10')
    t.column('valid_from', dtype='D')
    t.column('price', dtype='money')
    t.compositeColumn('pk', columns='product_code,valid_from')
pl.insert({'product_code': 'P1', 'valid_from': date(2026, 1, 1), 'price': 10})
# ... 'pk': '["P1", "2026-01-01::D"]'
pl.update({'product_code': 'P1', 'valid_from': date(2026, 1, 1), 'price': 12},
          pkey='["P1", "2026-01-01::D"]')
pl.delete('["P1", "2026-01-01::D"]')

Bullet: "A Bag value is written as its XML text; an empty Bag as NULL."

Scene 09

Recap

Insert and update change and return the record you pass. Update takes its key from the old record, the pkey argument, or the record, and it never reads the row. Delete by key reads the row with a lock, and delete by record must remove exactly one row. The next lesson lists the steps that run inside each of these commands, in their order.

Example & screen notes

three bullets. - insert and update change and return the caller's record - update takes the key from old_record, pkey, then the record, and never reads the row - delete by key rereads with a lock; by record it must remove exactly one row

Next: "Lesson 21 — The order of the write cycle".